diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8dd138d..c582a75 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,6 @@ + +[pull_request_template.md](https://github.com/user-attachments/files/23431873/pull_request_template.md) + ## ๐Ÿ“ ์š”์•ฝ(Summary) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e0c5a99 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,80 @@ +# ์›Œํฌํ”Œ๋กœ์šฐ์˜ ์ด๋ฆ„ +name: Spring Boot CI/CD with Gradle + +# ์›Œํฌํ”Œ๋กœ์šฐ๊ฐ€ ์‹คํ–‰๋  ์‹œ์ (์ด๋ฒคํŠธ)์„ ์ •์˜ +on: + # dev ๋ธŒ๋žœ์น˜๋กœ push ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ + push: + branches: [ "dev" ] + +jobs: + # 'build-and-deploy'๋ผ๋Š” ์ด๋ฆ„์˜ Job ์ •์˜ + build-and-deploy: + # ์ด Job์„ ์‹คํ–‰ํ•  ๊ฐ€์ƒ ๋จธ์‹  ํ™˜๊ฒฝ (Ubuntu ์ตœ์‹  ๋ฒ„์ „) + runs-on: ubuntu-latest + + # Job ๋‚ด๋ถ€์—์„œ ์‹คํ–‰๋  ๋‹จ๊ณ„(Step)๋“ค + steps: + # 1. ์ฝ”๋“œ ์ฒดํฌ์•„์›ƒ + - name: Checkout Repository + uses: actions/checkout@v4 + + # 2. JDK 17 ์„ค์น˜ + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + # 3. Gradle ์บ์‹œ ์„ค์ • (๋นŒ๋“œ ์†๋„ ํ–ฅ์ƒ) + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # 4. gradlew ์‹คํ–‰ ๊ถŒํ•œ ๋ถ€์—ฌ + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # 5. Gradle๋กœ ๋นŒ๋“œ (ํ…Œ์ŠคํŠธ ์Šคํ‚ต์„ ์›ํ•˜๋ฉด -x test ์ถ”๊ฐ€) + - name: Build with Gradle + run: ./gradlew bootJar -x test + + # ======================================================= + # 5-1. (์ด ๋‹จ๊ณ„๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”!!!) ๋นŒ๋“œ ํด๋” ๋‚ด์šฉ ํ™•์ธ + # ======================================================= + - name: Check build/libs directory + run: | + echo "--- Contents of build/libs ---" + ls -l build/libs/ + echo "------------------------------" + # ======================================================= + # 6. (CD ์‹œ์ž‘) ๋นŒ๋“œ๋œ JAR ํŒŒ์ผ์„ EC2๋กœ ์ „์†ก + # ======================================================= + - name: Copy JAR to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} # (ํ•„์ˆ˜) GitHub Secret์—์„œ ๊ฐ€์ ธ์˜ด + username: ${{ secrets.EC2_USERNAME }} # (ํ•„์ˆ˜) GitHub Secret์—์„œ ๊ฐ€์ ธ์˜ด + key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} # (ํ•„์ˆ˜) GitHub Secret์—์„œ ๊ฐ€์ ธ์˜ด + port: 22 # SSH ํฌํŠธ (๊ธฐ๋ณธ 22) + source: "build/libs/*.jar" # ๋กœ์ปฌ(GitHub Runner)์˜ .jar ํŒŒ์ผ ์œ„์น˜ + target: "/home/ubuntu/server/jars" # ์›๊ฒฉ(EC2) ์„œ๋ฒ„์— ์ €์žฅ๋  ๊ฒฝ๋กœ ๋ฐ ์ด๋ฆ„ + + # ======================================================= + # 7. (CD ์™„๋ฃŒ) EC2์— ์ ‘์†ํ•˜์—ฌ ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ + # ======================================================= + - name: Execute deployment script on EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + script: | + echo "Starting deployment script..." + /home/ubuntu/server/deploy.sh # (๊ฒฝ๋กœ ์ฃผ์˜) EC2์— ์žˆ๋Š” ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab86540 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +#Spring ์„ค์ • ํŒŒ์ผ +application.yml +src/main/resources/application-secret.yml +src/main/resources/members.txt +src/main/resources/firebase-service-account.json +!src/test/resources/application.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..ce5f517 --- /dev/null +++ b/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.7' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' +description = '์ค‘์ปคํ†ค - ์‚ฌ์ž์˜ ์ˆฒ ์Šคํ”„๋ง ํ”„๋กœ์ ํŠธ' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' //๊ตฌ๊ธ€ oauth + // JJWT (Java JWT) ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // @Valid ์–ด๋…ธํ…Œ์ด์…˜์„ ์œ„ํ•œ ์˜์กด์„ฑ + implementation 'org.springframework.boot:spring-boot-starter-validation' + // s3 AWS SDK v2 (์ตœ์‹  ๋ฒ„์ „) + implementation 'software.amazon.awssdk:s3:2.20.26' + // mysql jdbc ๋“œ๋ผ์ด๋ฒ„ ์˜์กด์„ฑ + runtimeOnly 'com.mysql:mysql-connector-j' + // ์Šค์›จ๊ฑฐ + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' + //๊ตฌ๊ธ€๋กœ๊ทธ์ธ-firebase + implementation 'com.google.firebase:firebase-admin:9.3.0' + // ํ…Œ์ŠคํŠธ ์‹œ์—๋งŒ ์‚ฌ์šฉํ•  H2 ์ธ๋ฉ”๋ชจ๋ฆฌ DB + testImplementation 'com.h2database:h2' +} + +tasks.named('test') { + useJUnitPlatform() +} + +jar{ + enabled = false +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/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/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@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/settings.gradle b/settings.gradle new file mode 100644 index 0000000..523a3ee --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'lionsforest' diff --git a/src/main/java/com/example/lionsforest/LionsforestApplication.java b/src/main/java/com/example/lionsforest/LionsforestApplication.java new file mode 100644 index 0000000..e826e3e --- /dev/null +++ b/src/main/java/com/example/lionsforest/LionsforestApplication.java @@ -0,0 +1,17 @@ +package com.example.lionsforest; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableJpaAuditing +@EnableScheduling +public class LionsforestApplication { + + public static void main(String[] args) { + SpringApplication.run(LionsforestApplication.class, args); + } + +} diff --git a/src/main/java/com/example/lionsforest/domain/comment/Comment.java b/src/main/java/com/example/lionsforest/domain/comment/Comment.java new file mode 100644 index 0000000..a0b7963 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/Comment.java @@ -0,0 +1,40 @@ +package com.example.lionsforest.domain.comment; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.HashSet; +import java.util.Set; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Comment extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long commentId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id", nullable = false) + private Group group; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private String content; + + //์ด ๋Œ“๊ธ€์„ ์ข‹์•„์š”ํ•œ ์œ ์ € + @Builder.Default + @ManyToMany(mappedBy = "liked_comments") + private Set likedByUsers = new HashSet<>(); +} diff --git a/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..4c6381c --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java @@ -0,0 +1,88 @@ +package com.example.lionsforest.domain.comment.controller; + +import com.example.lionsforest.domain.comment.dto.request.CommentRequestDto; +import com.example.lionsforest.domain.comment.dto.response.CommentLikeResponseDTO; +import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; +import com.example.lionsforest.domain.comment.service.CommentService; +import com.example.lionsforest.global.config.PrincipalHandler; +import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/comments/") +@Tag(name = "๋Œ“๊ธ€", description = "๋Œ“๊ธ€ ๊ด€๋ จ API") +public class CommentController { + private final CommentService commentService; + + // ๋Œ“๊ธ€ ์ƒ์„ฑ + @PostMapping("{group_id}/") + @Operation(summary = "๋Œ“๊ธ€ ์ƒ์„ฑ", description = """ + ์š”์ฒญ ํ˜•์‹: application/json + - content : string + + ### ๐Ÿ’ป ํ”„๋ก ํŠธ ์ „์†ก ์˜ˆ์‹œ (Axios) + ```javascript + await axios.post("/api/comments", { + content: "์ข‹์€ ๊ธ€ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค!" + }, { + headers: { "Content-Type": "application/json" } + }); + + """) + public ResponseEntity createComment(@PathVariable("group_id") Long groupId, + @RequestBody CommentRequestDto dto, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + return ResponseEntity.ok(commentService.createComment(groupId, dto, loginUserId)); + } + + // ๋ชจ์ž„๋ณ„ ๋Œ“๊ธ€ ์กฐํšŒ + @GetMapping("{group_id}/") + @Operation(summary = "๋ชจ์ž„๋ณ„ ๋Œ“๊ธ€ ์กฐํšŒ", description = "ํŠน์ • ๋ชจ์ž„(By group_id)์— ๋Œ€ํ•œ ๋Œ“๊ธ€์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity> getCommentByGroup(@PathVariable("group_id") Long groupId){ + return ResponseEntity.ok(commentService.getCommentsByGroupId(groupId)); + } + + // ๋Œ“๊ธ€ ์‚ญ์ œ + @DeleteMapping("{comment_id}/") + @Operation(summary = "๋Œ“๊ธ€ ์‚ญ์ œ", description = "ํŠน์ • ๋Œ“๊ธ€(By comment_id)์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity deleteComment(@PathVariable("comment_id") Long commentId, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal) { + Long loginUserId = Long.valueOf(principal.getUsername()); + + commentService.deleteComment(commentId, loginUserId); + return ResponseEntity.ok("๋Œ“๊ธ€์ด ์‚ญ์ œ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + // ํŠน์ • ๋Œ“๊ธ€ ์ข‹์•„์š” (Toggle) + @PostMapping("{comment_id}/like/") + @Operation(summary = "ํŠน์ • ๋Œ“๊ธ€ ์ข‹์•„์š” ์ƒ์„ฑ/์‚ญ์ œ(Toggle)", description = "ํŠน์ • ๋Œ“๊ธ€(By comment_id)์— ๋Œ€ํ•œ ์ข‹์•„์š”๋ฅผ ์ƒ์„ฑ/์‚ญ์ œ") + public ResponseEntity toggleLike( + @PathVariable("comment_id") Long commentId, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal + ) { + Long loginUserId = Long.valueOf(principal.getUsername()); + + String message = commentService.toggleLike(commentId, loginUserId); + return ResponseEntity.ok(message); + } + + //๋Œ“๊ธ€ ์ข‹์•„์š” ๋ˆŒ๋ €๋Š”์ง€ ํ™•์ธ + @GetMapping("{comment_id}/like/status") + @Operation(summary = "ํŠน์ • ๋Œ“๊ธ€ ์ข‹์•„์š” ํ™•์ธ", description = "ํ•ด๋‹น ์œ ์ €๊ฐ€ comment_id์— ์ข‹์•„์š”๋ฅผ ๋ˆŒ๋ €๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity viewCommentLike( + @PathVariable("comment_id") Long commentId + ){ + Long authenticatedUserId = PrincipalHandler.getUserId(); + CommentLikeResponseDTO response = commentService.isLiked(commentId, authenticatedUserId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/comment/dto/request/CommentRequestDto.java b/src/main/java/com/example/lionsforest/domain/comment/dto/request/CommentRequestDto.java new file mode 100644 index 0000000..e5b9577 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/dto/request/CommentRequestDto.java @@ -0,0 +1,10 @@ +package com.example.lionsforest.domain.comment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class CommentRequestDto { + @NotBlank(message = "๋Œ“๊ธ€ ๋‚ด์šฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.") + private String content; +} diff --git a/src/main/java/com/example/lionsforest/domain/comment/dto/response/CommentLikeResponseDTO.java b/src/main/java/com/example/lionsforest/domain/comment/dto/response/CommentLikeResponseDTO.java new file mode 100644 index 0000000..ba4820b --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/dto/response/CommentLikeResponseDTO.java @@ -0,0 +1,17 @@ +package com.example.lionsforest.domain.comment.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class CommentLikeResponseDTO { + private final boolean isLiked; + + // ์ •์  ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ + public static CommentLikeResponseDTO of(boolean isLiked) { + return CommentLikeResponseDTO.builder() + .isLiked(isLiked) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/comment/dto/response/CommentResponseDto.java b/src/main/java/com/example/lionsforest/domain/comment/dto/response/CommentResponseDto.java new file mode 100644 index 0000000..a3e74d4 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/dto/response/CommentResponseDto.java @@ -0,0 +1,37 @@ +package com.example.lionsforest.domain.comment.dto.response; + +import com.example.lionsforest.domain.comment.Comment; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class CommentResponseDto { + private Long id; + private Long groupId; + private Long userId; + private String profilePhotoUrl; + private String userName; + private String userNickName; + private String content; + private int likeCount; + private LocalDateTime createdAt; + + public static CommentResponseDto fromEntity(Comment comment){ + return CommentResponseDto.builder() + .id(comment.getCommentId()) + .groupId(comment.getGroup().getId()) + .userId(comment.getUser().getId()) + .profilePhotoUrl(comment.getUser().getProfile_photo()) + .userName(comment.getUser().getName()) + .userNickName(comment.getUser().getNickname()) + .content(comment.getContent()) + .likeCount(comment.getLikedByUsers().size()) // ์ข‹์•„์š” ์ˆ˜ + .createdAt(comment.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java b/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..f60618f --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java @@ -0,0 +1,16 @@ +package com.example.lionsforest.domain.comment.repository; + +import com.example.lionsforest.domain.comment.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CommentRepository extends JpaRepository { + + // ๋ชจ์ž„๋ณ„ ๋Œ“๊ธ€ ์กฐํšŒ + List findByGroupId(Long groupId); + // ์œ ์ €๋ณ„ ๋Œ“๊ธ€ ์กฐํšŒ + List findByUserId(Long userId); + // ๋Œ“๊ธ€์— ์ข‹์•„์š” ๋ˆ„๋ฅธ ์œ ์ € ์กฐํšŒ + boolean existsByCommentIdAndLikedByUsers_Id(Long commentId, Long userId); +} diff --git a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java new file mode 100644 index 0000000..938b34c --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -0,0 +1,172 @@ +package com.example.lionsforest.domain.comment.service; + +import com.example.lionsforest.domain.comment.Comment; +import com.example.lionsforest.domain.comment.dto.request.CommentRequestDto; +import com.example.lionsforest.domain.comment.dto.response.CommentLikeResponseDTO; +import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; +import com.example.lionsforest.domain.comment.repository.CommentRepository; +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.GroupPhoto; +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.group.repository.GroupPhotoRepository; +import com.example.lionsforest.domain.group.repository.GroupRepository; +import com.example.lionsforest.domain.group.repository.ParticipationRepository; +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.notification.TargetType; +import com.example.lionsforest.domain.notification.repository.NotificationRepository; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class CommentService { + private final CommentRepository commentRepository; + private final GroupRepository groupRepository; + private final UserRepository userRepository; + private final ParticipationRepository participationRepository; + private final NotificationRepository notificationRepository; + private final GroupPhotoRepository groupPhotoRepository; + + // ๋Œ“๊ธ€ ์ƒ์„ฑ + @Transactional + public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, Long userId){ + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Comment comment = Comment.builder() + .group(group) + .content(dto.getContent()) + .user(user) + .build(); + + Comment saved = commentRepository.save(comment); + + // ์•Œ๋ฆผ ์ƒ์„ฑ: ๋ชจ์ž„์˜ ๋ชจ๋“  ์ฐธ์—ฌ์ž์—๊ฒŒ ์ƒˆ ๋Œ“๊ธ€ ์•Œ๋ฆผ + String dateStr = group.getMeetingAt().format(DateTimeFormatter.ofPattern("yy.MM.dd")); + String content = "๐Ÿ’ฌ [" + dateStr + "] " + group.getTitle() + " ๋ชจ์ž„์— ์ƒˆ๋กœ์šด ๋Œ“๊ธ€์ด ๋‹ฌ๋ ธ์–ด์š”."; + // ๋ชจ์ž„ ์ฒซ ์‚ฌ์ง„ + String photoPath = null; + Optional firstPhotoOpt = groupPhotoRepository.findFirstByGroupIdOrderByPhotoOrderAsc(groupId); + if (firstPhotoOpt.isPresent()) { + photoPath = firstPhotoOpt.get().getPhoto(); + } + // ํ•ด๋‹น ๋ชจ์ž„์˜ ์ „์ฒด ์ฐธ์—ฌ์ž ๋ชฉ๋ก (๋ชจ์ž„์žฅ ํฌํ•จ) + List participations = participationRepository.findByGroupId(groupId); + for (Participation part : participations) { + Long targetUserId = part.getUser().getId(); + if (!targetUserId.equals(userId)) { + // ๋Œ“๊ธ€ ์ž‘์„ฑ์ž ๋ณธ์ธ์—๊ฒŒ๋Š” ์•Œ๋ฆผ ๋ณด๋‚ด์ง€ ์•Š์Œ + Notification notification = Notification.builder() + .user(part.getUser()) + .content(content) + .photo(photoPath) + .targetId(groupId) + .targetType(TargetType.GROUP) + .build(); + notificationRepository.save(notification); + } + } + + return CommentResponseDto.fromEntity(saved); + } + + // ๋Œ“๊ธ€ ์‚ญ์ œ + @Transactional + public void deleteComment(Long commentId, Long userId){ + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new BusinessException(ErrorCode.COMMENT_PERMISSION_DENIED); + } + + commentRepository.delete(comment); + } + + // ๋ชจ์ž„๋ณ„ ๋Œ“๊ธ€ ์กฐํšŒ + @Transactional(readOnly = true) + public List getCommentsByGroupId(Long groupId){ + List comments = commentRepository.findByGroupId(groupId); + + return comments.stream() + .map(CommentResponseDto::fromEntity) + .toList(); + } + + // ๋Œ“๊ธ€ ์ข‹์•„์š” ์ƒ์„ฑ/์ทจ์†Œ + @Transactional + public String toggleLike(Long commentId, Long userId){ + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + + Group group = comment.getGroup(); + + // @ManyToMany์˜ ์ฃผ์ธ(User) ์—”ํ‹ฐํ‹ฐ๋ฅผ ๊ฐ€์ ธ์™€์•ผ ํ•จ + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // User ์—”ํ‹ฐํ‹ฐ์˜ liked_comments Set์„ ๊ฐ€์ ธ์˜ด + Set likedComments = user.getLiked_comments(); + + if (likedComments.contains(comment)) { + // ์ด๋ฏธ ์ข‹์•„์š” ๋ˆ„๋ฆ„ -> ์ข‹์•„์š” ์ทจ์†Œ + likedComments.remove(comment); + return "์ข‹์•„์š”๊ฐ€ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."; + } else { + // ์ข‹์•„์š” ์•ˆ ๋ˆ„๋ฆ„ -> ์ข‹์•„์š” ์ถ”๊ฐ€ + likedComments.add(comment); + + // ์•Œ๋ฆผ ์ƒ์„ฑ: ๋Œ“๊ธ€ ์ž‘์„ฑ์ž์—๊ฒŒ ์ข‹์•„์š” ์•Œ๋ฆผ ๋ณด๋‚ด๊ธฐ + User author = comment.getUser(); + if (!author.getId().equals(userId)) { // ๋ณธ์ธ์˜ ๋Œ“๊ธ€์ด ์•„๋‹Œ ๊ฒฝ์šฐ๋งŒ + String photoPath = null; + Optional firstPhotoOpt = groupPhotoRepository.findFirstByGroupIdOrderByPhotoOrderAsc(comment.getGroup().getId()); + if (firstPhotoOpt.isPresent()) { + photoPath = firstPhotoOpt.get().getPhoto(); + } + + String dateStr = group.getMeetingAt().format(DateTimeFormatter.ofPattern("yy.MM.dd")); + String content = "โ™ฅ๏ธ ["+ dateStr + "] " + group.getTitle() + " ๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ๋Œ“๊ธ€์— ํ•˜ํŠธ๊ฐ€ ๋‹ฌ๋ ธ์–ด์š”."; + + Notification notification = Notification.builder() + .user(author) + .content(content) + .photo(photoPath) + .targetId(group.getId()) + .targetType(TargetType.GROUP) + .build(); + notificationRepository.save(notification); + } + + return "์ข‹์•„์š”๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค."; + } + } + + //์ข‹์•„์š” ๋ˆŒ๋ €๋Š”์ง€ ํ™•์ธ + public CommentLikeResponseDTO isLiked(Long commentId, Long userId){ + Comment comment = commentRepository.findById(commentId) + .orElseThrow(()->new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + + // ํ˜„์žฌ ์œ ์ €์˜ ์ข‹์•„์š” ์ƒํƒœ ํ™•์ธ (๋ฉ”์„œ๋“œ ์ด๋ฆ„ ์ฟผ๋ฆฌ ์‚ฌ์šฉ) + boolean isLiked = commentRepository.existsByCommentIdAndLikedByUsers_Id(commentId, userId); + + return CommentLikeResponseDTO.of(isLiked); + } + +} diff --git a/src/main/java/com/example/lionsforest/domain/group/Group.java b/src/main/java/com/example/lionsforest/domain/group/Group.java new file mode 100644 index 0000000..14fe293 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/Group.java @@ -0,0 +1,70 @@ +package com.example.lionsforest.domain.group; + +import com.example.lionsforest.domain.comment.Comment; +import com.example.lionsforest.domain.review.Review; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "`Group`") +public class Group extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 63) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private GroupCategory category; + + @Column(nullable = false) + private Integer capacity; // ๋ชจ์ง‘ ์ •์› + + @Column(nullable = false) + private LocalDateTime meetingAt; + + private String location; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private GroupState state; + + //๋ชจ์ž„์žฅ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "leader_id", nullable = false) + private User leader; + + //์ฐธ์—ฌ์ž๋“ค + @Builder.Default + @OneToMany(mappedBy = "group", cascade = CascadeType.ALL) + private List participations = new ArrayList<>(); + + //๋ชจ์ž„ ์‚ฌ์ง„ + @Builder.Default + @OneToMany(mappedBy = "group", cascade = CascadeType.ALL) + private List photos = new ArrayList<>(); + + //๋ชจ์ž„ ๋Œ“๊ธ€ + @Builder.Default + @OneToMany(mappedBy = "group", cascade = CascadeType.ALL) + private List comments = new ArrayList<>(); + + //๋ชจ์ž„ ํ›„๊ธฐ + @Builder.Default + @OneToMany(mappedBy = "group", cascade = CascadeType.ALL) + private List reviews = new ArrayList<>(); + +} diff --git a/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java b/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java new file mode 100644 index 0000000..9be5930 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java @@ -0,0 +1,9 @@ +package com.example.lionsforest.domain.group; + +public enum GroupCategory { + MEAL, //์‹์‚ฌ + WORK, //๋ชจ๊ฐ์ž‘ + SOCIAL, //์†Œ๋ชจ์ž„ + CULTURE, //๋ฌธํ™”์˜ˆ์ˆ  + ETC //๊ธฐํƒ€ +} diff --git a/src/main/java/com/example/lionsforest/domain/group/GroupPhoto.java b/src/main/java/com/example/lionsforest/domain/group/GroupPhoto.java new file mode 100644 index 0000000..5d05bec --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/GroupPhoto.java @@ -0,0 +1,28 @@ +package com.example.lionsforest.domain.group; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class GroupPhoto { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id", nullable = false) + private Group group; + + @Column(nullable = false) + private String photo; + + @Column(nullable = false) + private Integer photoOrder; +} diff --git a/src/main/java/com/example/lionsforest/domain/group/GroupState.java b/src/main/java/com/example/lionsforest/domain/group/GroupState.java new file mode 100644 index 0000000..573b052 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/GroupState.java @@ -0,0 +1,6 @@ +package com.example.lionsforest.domain.group; + +public enum GroupState { + OPEN, //๋ชจ์ง‘ ์ค‘ + CLOSED //๋ชจ์ง‘ ์™„๋ฃŒ +} diff --git a/src/main/java/com/example/lionsforest/domain/group/Participation.java b/src/main/java/com/example/lionsforest/domain/group/Participation.java new file mode 100644 index 0000000..d5a4d70 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/Participation.java @@ -0,0 +1,28 @@ +package com.example.lionsforest.domain.group; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Participation extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id", nullable = false) + private Group group; +} diff --git a/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java new file mode 100644 index 0000000..627f2aa --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -0,0 +1,126 @@ +package com.example.lionsforest.domain.group.controller; + +import com.example.lionsforest.domain.group.dto.request.GroupRequestDto; +import com.example.lionsforest.domain.group.dto.request.GroupUpdateRequestDto; +import com.example.lionsforest.domain.group.dto.response.GroupGetResponseDto; +import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; +import com.example.lionsforest.domain.group.dto.response.GroupSimpleInfoResponseDto; +import com.example.lionsforest.domain.group.service.GroupService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/groups/") +@Tag(name = "๋ชจ์ž„", description = "๋ชจ์ž„ ๊ด€๋ จ API") +public class GroupController { + + private final GroupService groupService; + + // ๋ชจ์ž„ ๊ฐœ์„ค + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "๋ชจ์ž„ ๊ฐœ์„ค", description = """ + ์š”์ฒญ ํ˜•์‹: multipart/form-data + - title: string + - category: MEAL(์‹์‚ฌ) | WORK(๋ชจ๊ฐ์ž‘) | CAFE(์นดํŽ˜) | SOCIAL(์†Œ๋ชจ์ž„) | CULTURE(๋ฌธํ™”์˜ˆ์ˆ ) | ETC(๊ธฐํƒ€) + - capacity: int (2~50) + - meetingAt: ISO-8601 (์˜ˆ: 2025-11-15T14:00:00) + - location: string + - photos: ์ด๋ฏธ์ง€ ํŒŒ์ผ ์—ฌ๋Ÿฌ ๊ฐœ (๋™์ผ ํ‚ค 'photos'๋กœ append) + + ### ๐Ÿ’ป ํ”„๋ก ํŠธ ์ „์†ก ์˜ˆ์‹œ (Axios) + ```javascript + const form = new FormData(); + form.append("title", "์ฃผ๋ง ๋“ฑ์‚ฐ ๋ชจ์ž„"); + form.append("category", "MEAL"); + form.append("capacity", "10"); + form.append("meetingAt", "2025-11-15T14:00:00"); + form.append("location", "์„œ์šธ ๋ถํ•œ์‚ฐ ์ž…๊ตฌ"); + files.forEach(f => form.append("photos", f)); // ๋™์ผ ํ‚ค๋กœ ์—ฌ๋Ÿฌ ๋ฒˆ append + + await axios.post("/api/groups/", form, { + headers: { "Content-Type": "multipart/form-data" } + }); + + """) + @io.swagger.v3.oas.annotations.parameters.RequestBody( // Swagger ๋ฌธ์„œํ™”์šฉ + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(implementation = GroupRequestDto.class) + )) + public ResponseEntity createGroup(@ModelAttribute GroupRequestDto req, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + + Long loginUserId = Long.valueOf(principal.getUsername()); + + GroupResponseDto responseDto = groupService.createGroup(req, loginUserId); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + } + + // ๋ชจ์ž„ ์ •๋ณด ์ „์ฒด ์กฐํšŒ + @GetMapping + @Operation(summary = "๋ชจ์ž„ ์ •๋ณด ์ „์ฒด ์กฐํšŒ", description = "๊ฐœ์„ค๋œ ๋ชจ์ž„์„ ๋ชจ๋‘ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity> getAllGroups(){ + return ResponseEntity.ok(groupService.getAllGroup()); + } + + // ๋ชจ์ž„ ์ •๋ณด ์ƒ์„ธ ์กฐํšŒ + @GetMapping("{group_id}/") + @Operation(summary = "๋ชจ์ž„ ์ •๋ณด ์ƒ์„ธ ์กฐํšŒ", description = "ํŠน์ • ๋ชจ์ž„(By group_id)์— ๋Œ€ํ•œ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity getGroupByID(@PathVariable("group_id") Long groupId){ + GroupGetResponseDto responseDto = groupService.getGroupById(groupId); + return ResponseEntity.ok(responseDto); + } + + // ๋‚ด๊ฐ€ ๊ฐœ์„คํ•œ ๋ชจ์ž„ ์ „์ฒด ์กฐํšŒ + @GetMapping("leader/") + @Operation(summary = "๋‚ด๊ฐ€ ๊ฐœ์„คํ•œ ๋ชจ์ž„ ์ „์ฒด ์กฐํšŒ", description = "๋‚ด๊ฐ€ ๊ฐœ์„คํ•œ ๋ชจ์ž„์— ๋Œ€ํ•œ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity> getAllGroupByLeader(@AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + List responseDto = groupService.getAllGroupByLeader(loginUserId); + return ResponseEntity.ok(responseDto); + } + + // ๋ชจ์ž„ ์ •๋ณด ์ˆ˜์ • + @PatchMapping("{group_id}/") + @Operation(summary = "๋ชจ์ž„ ์ •๋ณด ์ˆ˜์ •", description = "ํŠน์ • ๋ชจ์ž„(By group_id)์˜ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค(์‚ฌ์ง„ ์ œ์™ธ)") + public ResponseEntity updateGroup(@PathVariable("group_id") Long groupId, + @RequestBody GroupUpdateRequestDto dto, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + return ResponseEntity.ok(groupService.updateGroup(groupId, dto, loginUserId)); + } + + // ๋ชจ์ž„ ์‚ญ์ œ + @DeleteMapping("{group_id}/") + @Operation(summary = "๋ชจ์ž„ ์‚ญ์ œ", description = "ํŠน์ • ๋ชจ์ž„(By group_id)์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity deleteGroup(@PathVariable("group_id") Long groupId, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + groupService.deleteGroup(groupId, loginUserId); + + return ResponseEntity.ok("๋ชจ์ž„์ด ์„ฑ๊ณต์ ์œผ๋กœ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + //๋ชจ์ž„ ์ •๋ณด ๊ฐ„๋‹จ ์กฐํšŒ + @GetMapping("{group_id}/simple") + @Operation(summary = "๋ชจ์ž„ ์ •๋ณด ๊ฐ„๋‹จ ์กฐํšŒ", description = "ํ›„๊ธฐ ์ž‘์„ฑ ์‹œ ์ƒ๋‹จ์— ํ‘œ์‹œํ•  ํŠน์ • ๋ชจ์ž„์˜ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.") + public ResponseEntity getGroupSimpleInfo(@PathVariable("group_id") Long groupId){ + GroupSimpleInfoResponseDto response = groupService.getGroupSimpleInfo(groupId); + return ResponseEntity.ok(response); + } + +} diff --git a/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java b/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java new file mode 100644 index 0000000..b209179 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java @@ -0,0 +1,62 @@ +package com.example.lionsforest.domain.group.controller; + +import com.example.lionsforest.domain.group.dto.response.GroupGetResponseDto; +import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; +import com.example.lionsforest.domain.group.service.ParticipationService; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/participation/") +@Tag(name = "๋ชจ์ž„ ์ฐธ์—ฌ", description = "๋ชจ์ž„ ์ฐธ์—ฌ ๊ด€๋ จ API") +public class ParticipationController { + private final ParticipationService participationService; + private final UserRepository userRepository; + + // ๋ชจ์ž„ ์ฐธ์—ฌ + @PostMapping("{group_id}/") + @Operation(summary = "๋ชจ์ž„ ์ฐธ์—ฌ", description = "ํŠน์ • ๋ชจ์ž„(By group_id)์— ์ฐธ์—ฌํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity joinGroup(@PathVariable("group_id") Long groupId, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + return ResponseEntity.ok(participationService.joinGroup(groupId, loginUserId)); + } + + // ๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ๋ชจ์ž„ ์กฐํšŒ + @GetMapping("my/") + @Operation(summary = "๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ๋ชจ์ž„ ์กฐํšŒ", description = "๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ๋ชจ์ž„์„ ๋ชจ๋‘ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity> getUser(@AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + return ResponseEntity.ok(participationService.getAllMyParticipations(loginUserId)); + } + + // ๋ชจ์ž„ ํƒˆํ‡ด + @DeleteMapping("{group_id}/") + @Operation(summary = "๋ชจ์ž„ ํƒˆํ‡ด", description = "ํŠน์ • ๋ชจ์ž„(By group_id)์—์„œ ํƒˆํ‡ดํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity leaveGroup(@PathVariable("group_id") Long groupId, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal) { + Long loginUserId = Long.valueOf(principal.getUsername()); + + participationService.leaveGroup(groupId, loginUserId); + return ResponseEntity.ok("๋ชจ์ž„์—์„œ ํƒˆํ‡ดํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + + // ๋ชจ์ž„ ์ฐธ์—ฌ์ž ์กฐํšŒ + @GetMapping("{group_id}/") + @Operation(summary = "๋ชจ์ž„ ์ฐธ์—ฌ์ž ์กฐํšŒ", description = "ํŠน์ • ๋ชจ์ž„(By group_id)์— ์ฐธ์—ฌ์ž๋ฅผ ๋ชจ๋‘ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity> getUser(@PathVariable("group_id") Long groupId){ + return ResponseEntity.ok(participationService.getParticipationsByGroupId(groupId)); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupRequestDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupRequestDto.java new file mode 100644 index 0000000..9960752 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupRequestDto.java @@ -0,0 +1,64 @@ +package com.example.lionsforest.domain.group.dto.request; + +import com.example.lionsforest.domain.group.GroupCategory; +import com.example.lionsforest.domain.group.GroupState; +import com.example.lionsforest.domain.user.User; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.Getter; +import com.example.lionsforest.domain.group.Group; +import lombok.NoArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Getter +@NoArgsConstructor +@Schema(description = "๋ชจ์ž„ ์ƒ์„ฑ ์š”์ฒญ") +public class GroupRequestDto { + @Schema(description = "๋ชจ์ž„ ์ œ๋ชฉ") + @NotBlank + private String title; + + @Schema(description = "๋ชจ์ž„ ์นดํ…Œ๊ณ ๋ฆฌ") + @NotNull + private GroupCategory category; + + @Schema(description = "๋ชจ์ž„ ์ •์›") + @NotNull + private Integer capacity; + + @Schema(description = "๋ชจ์ž„ ์ผ์‹œ") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @NotNull + private LocalDateTime meetingAt; + + @Schema(description = "๋ชจ์ž„ ์žฅ์†Œ") + @NotBlank + private String location; + + @ArraySchema( + arraySchema = @Schema(description = "์—…๋กœ๋“œํ•  ์‚ฌ์ง„๋“ค"), + schema = @Schema(type = "string", format = "binary") + ) + private List photos; + + public Group toEntity(User leader){ + return Group.builder() + .title(this.title) + .category(this.category) + .capacity(this.capacity) + .meetingAt(this.meetingAt) + .location(this.location) + .state(GroupState.OPEN) // ๊ธฐ๋ณธ ์„ค์ • : ๋ชจ์ง‘์ค‘ + .leader(leader) + .build(); + } + +} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupUpdateRequestDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupUpdateRequestDto.java new file mode 100644 index 0000000..402d4bf --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupUpdateRequestDto.java @@ -0,0 +1,17 @@ +package com.example.lionsforest.domain.group.dto.request; + +import com.example.lionsforest.domain.group.GroupCategory; +import com.example.lionsforest.domain.group.GroupState; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class GroupUpdateRequestDto { + private String title; + private GroupCategory category; + private Integer capacity; + private LocalDateTime meetingAt; + private String location; + private GroupState state; +} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetResponseDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetResponseDto.java new file mode 100644 index 0000000..fc146a8 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetResponseDto.java @@ -0,0 +1,55 @@ +package com.example.lionsforest.domain.group.dto.response; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.GroupCategory; +import com.example.lionsforest.domain.group.GroupPhoto; +import com.example.lionsforest.domain.group.GroupState; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class GroupGetResponseDto { + private Long id; + private Long leaderId; + private String leaderNickname; + private String leaderName; + private String title; + private GroupCategory category; + private Integer capacity; + private LocalDateTime meetingAt; + private String location; + private GroupState state; + private int participantCount; + + private List photos; + + public static GroupGetResponseDto fromEntity(Group group){ + + List photos = group.getPhotos().stream() + .sorted(Comparator.comparing(GroupPhoto::getPhotoOrder)) + .map(GroupPhotoDto::new) + .toList(); + + return GroupGetResponseDto.builder() + .id(group.getId()) + .leaderId(group.getLeader().getId()) + .leaderNickname(group.getLeader().getNickname()) + .leaderName(group.getLeader().getName()) + .title(group.getTitle()) + .category(group.getCategory()) + .capacity(group.getCapacity()) + .meetingAt(group.getMeetingAt()) + .location(group.getLocation()) + .state(group.getState()) + .photos(photos) + .participantCount(group.getParticipations().size()) // ํ˜„์žฌ ์ฐธ์—ฌ์ž ์ˆ˜ + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupPhotoDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupPhotoDto.java new file mode 100644 index 0000000..2e589d0 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupPhotoDto.java @@ -0,0 +1,16 @@ +package com.example.lionsforest.domain.group.dto.response; + +import com.example.lionsforest.domain.group.GroupPhoto; + +import lombok.Getter; + +@Getter +public class GroupPhotoDto { + private final String photoUrl; + private final Integer order; + + public GroupPhotoDto(GroupPhoto photo) { + this.photoUrl = photo.getPhoto(); + this.order = photo.getPhotoOrder(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupResponseDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupResponseDto.java new file mode 100644 index 0000000..cc1cfea --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupResponseDto.java @@ -0,0 +1,45 @@ +package com.example.lionsforest.domain.group.dto.response; + +import com.example.lionsforest.domain.group.GroupCategory; +import com.example.lionsforest.domain.group.GroupState; +import com.example.lionsforest.domain.group.Group; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Setter +@Getter +@Builder +@AllArgsConstructor +public class GroupResponseDto { + private Long id; + private Long leaderId; + private String leaderNickname; + private String leaderName; + private String title; + private GroupCategory category; + private Integer capacity; + private LocalDateTime meetingAt; + private String location; + private GroupState state; + private long participantCount; + + public static GroupResponseDto fromEntity(Group group){ + return GroupResponseDto.builder() + .id(group.getId()) + .leaderId(group.getLeader().getId()) + .leaderNickname(group.getLeader().getNickname()) + .leaderName(group.getLeader().getName()) + .title(group.getTitle()) + .category(group.getCategory()) + .capacity(group.getCapacity()) + .meetingAt(group.getMeetingAt()) + .location(group.getLocation()) + .state(group.getState()) + .participantCount(group.getParticipations().size()) // ํ˜„์žฌ ์ฐธ์—ฌ์ž ์ˆ˜ + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupSimpleInfoResponseDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupSimpleInfoResponseDto.java new file mode 100644 index 0000000..2836e41 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupSimpleInfoResponseDto.java @@ -0,0 +1,48 @@ +package com.example.lionsforest.domain.group.dto.response; + +import com.example.lionsforest.domain.group.*; +import com.example.lionsforest.domain.user.User; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +@Builder +@Getter +public class GroupSimpleInfoResponseDto { + private Long id; + private String title; + private GroupState state; + private LocalDateTime meetingAt; + private List participants; + private GroupCategory category; + private List photos; + + public static GroupSimpleInfoResponseDto fromEntity(Group group) { + + //์‚ฌ์ง„ ์ •๋ ฌ + List photos = group.getPhotos().stream() + .sorted(Comparator.comparing(GroupPhoto::getPhotoOrder)) + .map(GroupPhotoDto::new) + .toList(); + + + //์ฐธ์—ฌ์ž ์ด๋ฆ„ ๋ฆฌ์ŠคํŠธ๋กœ ๋ณ€ํ™˜ + List participants = group.getParticipations().stream() + .map(Participation::getUser) + .map(User::getName) + .toList(); + + return GroupSimpleInfoResponseDto.builder() + .id(group.getId()) + .title(group.getTitle()) + .state(group.getState()) + .meetingAt(group.getMeetingAt()) + .participants(participants) + .category(group.getCategory()) + .photos(photos) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/response/ParticipationResponseDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/ParticipationResponseDto.java new file mode 100644 index 0000000..b14db8c --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/ParticipationResponseDto.java @@ -0,0 +1,34 @@ +package com.example.lionsforest.domain.group.dto.response; + +import com.example.lionsforest.domain.group.Participation; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class ParticipationResponseDto { + private Long id; + private Long groupId; + private Long userId; + private String userName; + private String userNickname; + private String profilePhotoUrl; + private LocalDateTime createdAt; + + public static ParticipationResponseDto fromEntity(Participation participation){ + return ParticipationResponseDto.builder() + .id(participation.getId()) + .groupId(participation.getGroup().getId()) + .userId(participation.getUser().getId()) + .userName(participation.getUser().getName()) + .userNickname(participation.getUser().getNickname()) + .profilePhotoUrl(participation.getUser().getProfile_photo()) + .createdAt(participation.getCreatedAt()) + .build(); + } + +} diff --git a/src/main/java/com/example/lionsforest/domain/group/repository/GroupPhotoRepository.java b/src/main/java/com/example/lionsforest/domain/group/repository/GroupPhotoRepository.java new file mode 100644 index 0000000..7222391 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupPhotoRepository.java @@ -0,0 +1,16 @@ +package com.example.lionsforest.domain.group.repository; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.GroupPhoto; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface GroupPhotoRepository extends JpaRepository { + List findAllByGroup(Group group); + int countByGroupId(Long groupId); + + // ํŠน์ • ๋ชจ์ž„์˜ ์ฒซ ์‚ฌ์ง„ ๊ฐ€์ ธ์˜ค๊ธฐ + Optional findFirstByGroupIdOrderByPhotoOrderAsc(Long groupId); +} diff --git a/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java b/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java new file mode 100644 index 0000000..e4911ff --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java @@ -0,0 +1,24 @@ +package com.example.lionsforest.domain.group.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import com.example.lionsforest.domain.group.Group; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +public interface GroupRepository extends JpaRepository { + // ์ƒ์„ธ ์กฐํšŒ ์‹œ N+1 ๋ฌธ์ œ๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด fetch join ์‚ฌ์šฉ + @Query("SELECT g FROM Group g LEFT JOIN FETCH g.photos WHERE g.id = :id") + Optional findByIdWithPhotos(@Param("id") Long id); + + List findAllByLeaderId(Long leaderId); + + List findByMeetingAtBetween(LocalDateTime startRange, LocalDateTime endRange); + + @Modifying + @Query("UPDATE Group m SET m.state = 'CLOSED' WHERE m.meetingAt <= :currentTime AND m.state = 'OPEN'") + int closeMeetingByTime(@Param("currentTime") LocalDateTime currentTime); +} diff --git a/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java b/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java new file mode 100644 index 0000000..4958128 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java @@ -0,0 +1,23 @@ +package com.example.lionsforest.domain.group.repository; + +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + + +public interface ParticipationRepository extends JpaRepository { + boolean existsByGroupAndUser(Group group, User user); + + long countByGroupId(Long groupId); + + Optional findByGroupIdAndUserId(Long groupId, Long UserId); + + boolean existsByGroupIdAndUserId(Long groupId, Long userId); + + List findByGroupId(Long groupId); + List findByUserId(Long userId); +} diff --git a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java new file mode 100644 index 0000000..387643d --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -0,0 +1,232 @@ +package com.example.lionsforest.domain.group.service; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.group.dto.request.GroupRequestDto; +import com.example.lionsforest.domain.group.dto.response.GroupGetResponseDto; +import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; +import com.example.lionsforest.domain.group.dto.response.GroupSimpleInfoResponseDto; +import com.example.lionsforest.domain.group.repository.GroupRepository; +import com.example.lionsforest.domain.group.dto.request.GroupUpdateRequestDto; +import com.example.lionsforest.domain.group.GroupPhoto; +import com.example.lionsforest.domain.group.repository.GroupPhotoRepository; +import com.example.lionsforest.domain.group.repository.ParticipationRepository; +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.notification.repository.NotificationRepository; +import com.example.lionsforest.domain.user.User; + + +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.common.S3UploadService; +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class GroupService { + private final GroupRepository groupRepository; + private final GroupPhotoRepository groupPhotoRepository; + private final UserRepository userRepository; + private final S3UploadService s3UploadService; + private final ParticipationRepository participationRepository; + private final NotificationRepository notificationRepository; + + // ๋ชจ์ž„ ๊ฐœ์„ค + @Transactional + public GroupResponseDto createGroup(GroupRequestDto dto, Long userId){ + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // ๋ชจ์ž„ ์‹œ๊ฐ ๊ฒ€์ฆ - ํ˜„์žฌ ์‹œ๊ฐ ๋„˜์ง€ ์•Š๋Š”์ง€ + if(dto.getMeetingAt().isBefore(LocalDateTime.now())){ + throw new BusinessException(ErrorCode.GROUP_CREATION_TIME_EXCEEDED); + } + + // Group Entity ๋จผ์ € ์ƒ์„ฑ(ID ํ™•๋ณด) + Group group = dto.toEntity(user); + Group saved = groupRepository.save(group); + + List photos = dto.getPhotos(); + if (photos != null && !photos.isEmpty()) { + List groupPhotos = new ArrayList<>(); + for (int i = 0; i < photos.size(); i++) { + MultipartFile photo = photos.get(i); + + // S3์— ํŒŒ์ผ ์—…๋กœ๋“œ -> URL ๋ฐ˜ํ™˜ + String photoUrl = s3UploadService.upload(photo, "group-photos"); + // GroupPhoto ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ + GroupPhoto groupPhoto = GroupPhoto.builder() + .group(saved) // ์ €์žฅ๋œ Group ๊ฐ์ฒด + .photo(photoUrl) // S3์—์„œ ๋ฐ˜ํ™˜๋œ URL + .photoOrder(i) // ์‚ฌ์ง„ ์ˆœ์„œ (0๋ถ€ํ„ฐ ์‹œ์ž‘) + .build(); + + groupPhotos.add(groupPhoto); + } + // GroupPhoto ๋ฆฌ์ŠคํŠธ๋ฅผ DB์— ํ•œ ๋ฒˆ์— ์ €์žฅ (Batch Insert) + groupPhotoRepository.saveAll(groupPhotos); + } + + // ๋ชจ์ž„์žฅ์€ ๋ชจ์ž„ ์ž๋™ ์ฐธ์—ฌ + Participation leaderParticipation = Participation.builder() + .group(saved) + .user(user) + .build(); + participationRepository.save(leaderParticipation); + + long participantCount = participationRepository.countByGroupId(saved.getId()); + GroupResponseDto response = GroupResponseDto.fromEntity(saved); + response.setParticipantCount(participantCount); + return response; + } + + // ๋ชจ์ž„ ์ •๋ณด ์ „์ฒด ์กฐํšŒ + @Transactional(readOnly = true) + public List getAllGroup(){ + return groupRepository.findAll().stream() + .map(GroupGetResponseDto::fromEntity) + .collect(Collectors.toList()); + } + + + // ๋ชจ์ž„ ์ •๋ณด ์ƒ์„ธ ์กฐํšŒ + @Transactional(readOnly = true) + public GroupGetResponseDto getGroupById(Long groupId) { + + Group group = groupRepository.findByIdWithPhotos(groupId) + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); + + return GroupGetResponseDto.fromEntity(group); + } + + // ๋‚ด๊ฐ€ ๊ฐœ์„คํ•œ ๋ชจ์ž„ ์ „์ฒด ์กฐํšŒ + @Transactional(readOnly = true) + public List getAllGroupByLeader(Long userId){ + return groupRepository.findAllByLeaderId(userId).stream() + .map(GroupGetResponseDto::fromEntity) + .collect(Collectors.toList()); + } + + // ๋ชจ์ž„ ์ˆ˜์ • + @Transactional + public GroupResponseDto updateGroup(Long groupId, GroupUpdateRequestDto dto, Long userId){ + + // ๋ชจ์ž„ ์กฐํšŒ + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); + + // ์œ ์ € ์กฐํšŒ + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // ์œ ์ € ๊ถŒํ•œ ํ™•์ธ + if(!group.getLeader().getId().equals(user.getId())){ + throw new BusinessException(ErrorCode.GROUP_PERMISSION_DENIED); + } + + if (dto.getTitle() != null) { + group.setTitle(dto.getTitle()); + } + if (dto.getCategory() != null) { + group.setCategory(dto.getCategory()); + } + if (dto.getCapacity() != null) { + group.setCapacity(dto.getCapacity()); + } + if (dto.getMeetingAt() != null) { + group.setMeetingAt(dto.getMeetingAt()); + } + if (dto.getLocation() != null) { + group.setLocation(dto.getLocation()); + } + if (dto.getState() != null) { + group.setState(dto.getState()); + } + + return GroupResponseDto.fromEntity(group); + } + + // ๋ชจ์ž„ ์‚ญ์ œ + @Transactional + public void deleteGroup(Long groupId, Long userId){ + // ๋ชจ์ž„ ์กฐํšŒ + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); + + // ์œ ์ € ์กฐํšŒ + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // ๋ชจ์ž„ ์ทจ์†Œ ์‹œ์  ์ œํ•œ + if (group.getMeetingAt().isBefore(LocalDateTime.now())) { + throw new BusinessException(ErrorCode.GROUP_CANCEL_TIME_EXCEEDED); + } + + // ์œ ์ € ๊ถŒํ•œ ํ™•์ธ + if(!group.getLeader().getId().equals(user.getId())){ + throw new BusinessException(ErrorCode.GROUP_PERMISSION_DENIED); + } + + // ์•Œ๋ฆผ ์ƒ์„ฑ: ๋ชจ์ž„ ์ฐธ๊ฐ€์ž๋“ค์—๊ฒŒ ๋ชจ์ž„ ์ทจ์†Œ ์•Œ๋ฆผ ๋ณด๋‚ด๊ธฐ + // ํ•ด๋‹น ๋ชจ์ž„์˜ ๋ชจ๋“  ์ฐธ์—ฌ ๊ด€๊ณ„ ์กฐํšŒ (๋ชจ์ž„์žฅ ์ œ์™ธ) + List participations = participationRepository.findByGroupId(groupId); + // ๋ชจ์ž„ ์ฒซ ์‚ฌ์ง„ ๊ฐ€์ ธ์˜ค๊ธฐ + String photoPath = null; + Optional firstPhotoOpt = groupPhotoRepository.findFirstByGroupIdOrderByPhotoOrderAsc(groupId); + if (firstPhotoOpt.isPresent()) { + photoPath = firstPhotoOpt.get().getPhoto(); + } + // ์•Œ๋ฆผ ๋‚ด์šฉ ๊ตฌ์„ฑ (์˜ˆ: ๐Ÿ˜ข "[yy.MM.dd] ๋ชจ์ž„์ œ๋ชฉ" ๋ชจ์ž„์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.) + String dateStr = group.getMeetingAt().format(DateTimeFormatter.ofPattern("yy.MM.dd")); + String content = "๐Ÿ˜ข ["+ dateStr + "] " + group.getTitle() + " ๋ชจ์ž„์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."; + for (Participation part : participations) { + // ๋ชจ์ž„์žฅ์„ ์ œ์™ธํ•˜๊ณ  ์•Œ๋ฆผ ์ „์†ก (๋ชจ์ž„์žฅ ๋ณธ์ธ์€ ์•Œ๋ฆผ ์ƒ๋žต ๊ฐ€๋Šฅ) + if (!part.getUser().getId().equals(userId)) { + Notification notification = Notification.builder() + .user(part.getUser()) + .content(content) + .photo(photoPath) + .build(); + notificationRepository.save(notification); + } + } + + + // ์‚ฌ์ง„ ์‚ญ์ œ + if (group.getPhotos() != null) { + group.getPhotos().forEach(p -> s3UploadService.delete(p.getPhoto())); + } + + //์‚ญ์ œ + groupRepository.delete(group); + } + + //์‹œ๊ฐ„ ์ง€๋‚œ ๋ชจ์ž„ state ๋ณ€๊ฒฝ + @Transactional + public void closeExpiredMeetings(){ + int updatedCount = groupRepository.closeMeetingByTime(LocalDateTime.now()); + + if(updatedCount > 0){ + System.out.println(updatedCount + "๊ฐœ์˜ ๋ชจ์ž„์ด ๋งˆ๊ฐ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + } + + //๋ชจ์ž„ ๊ฐ„๋‹จ ์ •๋ณด ์กฐํšŒ + public GroupSimpleInfoResponseDto getGroupSimpleInfo(Long groupId){ + Group group = groupRepository.findByIdWithPhotos(groupId) + .orElseThrow(()->new BusinessException(ErrorCode.GROUP_NOT_FOUND)); + return GroupSimpleInfoResponseDto.fromEntity(group); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/group/service/MeetingScheduler.java b/src/main/java/com/example/lionsforest/domain/group/service/MeetingScheduler.java new file mode 100644 index 0000000..889c920 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/service/MeetingScheduler.java @@ -0,0 +1,20 @@ +package com.example.lionsforest.domain.group.service; + +import com.example.lionsforest.domain.group.repository.GroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class MeetingScheduler { + private final GroupService groupService; + + // 1๋ถ„๋งˆ๋‹ค ์‹คํ–‰ + @Scheduled(cron = "0 * * * * *") + public void closeExpiredMeetings() { + groupService.closeExpiredMeetings(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java new file mode 100644 index 0000000..7c0985d --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -0,0 +1,135 @@ +package com.example.lionsforest.domain.group.service; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.GroupPhoto; +import com.example.lionsforest.domain.group.GroupState; +import com.example.lionsforest.domain.group.dto.response.GroupGetResponseDto; +import com.example.lionsforest.domain.group.repository.GroupPhotoRepository; +import com.example.lionsforest.domain.group.repository.GroupRepository; +import com.example.lionsforest.domain.group.repository.ParticipationRepository; +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.notification.TargetType; +import com.example.lionsforest.domain.notification.repository.NotificationRepository; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; + +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ParticipationService { + private final ParticipationRepository participationRepository; + private final GroupRepository groupRepository; + private final UserRepository userRepository; + private final GroupPhotoRepository groupPhotoRepository; + private final NotificationRepository notificationRepository; + + // ๋ชจ์ž„ ์ฐธ์—ฌ + @Transactional + public ParticipationResponseDto joinGroup(Long groupId, Long userId){ + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // ์ค‘๋ณต ์ฐธ์—ฌ ์ฒดํฌ + if(participationRepository.existsByGroupAndUser(group, user)) { + throw new BusinessException(ErrorCode.PARTICIPATION_ALREADY_EXISTS); + } + + // ์ธ์› ์ œํ•œ ์ฒดํฌ + long currentCount = participationRepository.countByGroupId(groupId); + int capacity = group.getCapacity(); + if(currentCount >= capacity) { + throw new BusinessException(ErrorCode.GROUP_CAPACITY_FULL); + } + + Participation participation = Participation.builder() + .group(group) + .user(user) + .build(); + + Participation saved = participationRepository.save(participation); + + // ์•Œ๋ฆผ ์ƒ์„ฑ: ๋ณธ์ธ์—๊ฒŒ ์ฐธ์—ฌ ํ™•์ • ์•Œ๋ฆผ ๋ณด๋‚ด๊ธฐ + String dateStr = group.getMeetingAt().format(DateTimeFormatter.ofPattern("yy.MM.dd")); + String content = "โœ… [" + dateStr + "] " + group.getTitle() + " ๋ชจ์ž„์— ์ฐธ์—ฌ๊ฐ€ ํ™•์ •๋˜์—ˆ์–ด์š”!"; + // ๋ชจ์ž„ ๋Œ€ํ‘œ ์‚ฌ์ง„ ๊ฒฝ๋กœ ๊ฐ€์ ธ์˜ค๊ธฐ + String photoPath = null; + Optional firstPhotoOpt = groupPhotoRepository.findFirstByGroupIdOrderByPhotoOrderAsc(groupId); + if (firstPhotoOpt.isPresent()) { + photoPath = firstPhotoOpt.get().getPhoto(); + } + Notification notification = Notification.builder() + .user(user) // ๋ณธ์ธ์—๊ฒŒ + .content(content) + .photo(photoPath) + .targetId(groupId) + .targetType(TargetType.GROUP) + .build(); + notificationRepository.save(notification); + + long after = currentCount + 1; + if (after >= capacity && group.getState() != GroupState.CLOSED) { + group.setState(GroupState.CLOSED); // ๋ชจ์ง‘์™„๋ฃŒ๋กœ ์ „ํ™˜ + } + + return ParticipationResponseDto.fromEntity(saved); + } + + // ๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ๋ชจ์ž„ ๋ชฉ๋ก ์กฐํšŒ + @Transactional(readOnly = true) + public List getAllMyParticipations(Long userId){ + List participations = participationRepository.findByUserId(userId); + + return participations.stream() + .map(participation -> GroupGetResponseDto.fromEntity(participation.getGroup())) + .toList(); + } + + // ๋ชจ์ž„ ํƒˆํ‡ด + @Transactional + public void leaveGroup(Long groupId, Long userId) { + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (group.getLeader().getId().equals(userId)) { + throw new BusinessException(ErrorCode.GROUP_LEADER_CANNOT_LEAVE); + } + + Participation participation = participationRepository + .findByGroupIdAndUserId(groupId, user.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_PARTICIPATION_NOT_FOUND)); + + participationRepository.delete(participation); + + long after = participationRepository.countByGroupId(groupId); + if (after < group.getCapacity() && group.getState() == GroupState.CLOSED) { + group.setState(GroupState.OPEN); // ๋‹ค์‹œ ๋ชจ์ง‘์ค‘ + } + } + + // ๋ชจ์ž„ ์ฐธ์—ฌ์ž ์กฐํšŒ + @Transactional(readOnly = true) + public List getParticipationsByGroupId(Long groupId){ + List participations = participationRepository.findByGroupId(groupId); + + return participations.stream() + .map(ParticipationResponseDto::fromEntity) + .toList(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/notification/Notification.java b/src/main/java/com/example/lionsforest/domain/notification/Notification.java new file mode 100644 index 0000000..2fdd488 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/Notification.java @@ -0,0 +1,40 @@ +package com.example.lionsforest.domain.notification; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Notification extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private String content; + + private String photo; + + @Builder.Default + @Column(nullable = false) + private boolean isRead = false; + + private Long targetId; + + @Enumerated(EnumType.STRING) + private TargetType targetType; + + public void markRead() { this.isRead = true; } +} diff --git a/src/main/java/com/example/lionsforest/domain/notification/TargetType.java b/src/main/java/com/example/lionsforest/domain/notification/TargetType.java new file mode 100644 index 0000000..b635e04 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/TargetType.java @@ -0,0 +1,9 @@ +package com.example.lionsforest.domain.notification; + +public enum TargetType { + GROUP, + REVIEW, + COMMENT, + RADAR + +} diff --git a/src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java b/src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java new file mode 100644 index 0000000..b10ac0d --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java @@ -0,0 +1,68 @@ +package com.example.lionsforest.domain.notification.controller; + +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.notification.dto.response.NotificationResponseDto; +import com.example.lionsforest.domain.notification.repository.NotificationRepository; +import com.example.lionsforest.domain.notification.service.NotificationService; +import com.example.lionsforest.global.config.PrincipalHandler; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications/") +@Tag(name = "์•Œ๋ฆผ", description = "์•Œ๋ฆผ ๊ด€๋ จ API") +public class NotificationController { + private final NotificationRepository notificationRepository; + private final NotificationService notificationService; + + // ์•Œ๋ฆผ ๋ชฉ๋ก ์กฐํšŒ + @GetMapping("{user_id}/") + @Operation(summary = "์•Œ๋ฆผ ๋ชฉ๋ก ์กฐํšŒ", description = "์•Œ๋ฆผ์„ ๋ชจ๋‘ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public List getNotifications(@PathVariable(value = "user_id") Long userId) { + // ํŠน์ • ์‚ฌ์šฉ์ž์— ๋Œ€ํ•œ ๋ชจ๋“  ์•Œ๋ฆผ์„ ์ตœ์‹ ์ˆœ์œผ๋กœ ๊ฐ€์ ธ์˜ค๊ธฐ + List notifications = notificationRepository.findAllByUserIdOrderByCreatedAtDesc(userId); + // DTO๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋ฐ˜ํ™˜ + return notifications.stream() + .map(NotificationResponseDto::fromEntity) + .collect(Collectors.toList()); + } + + // ์•ˆ์ฝ์€ ์•Œ๋ฆผ ๊ฐœ์ˆ˜ ์กฐํšŒ + @GetMapping("{user_id}/unread/count/") + @Operation(summary = "์•ˆ์ฝ์€ ์•Œ๋ฆผ ๊ฐœ์ˆ˜ ์กฐํšŒ", description = "์•ˆ์ฝ์€ ์•Œ๋ฆผ ๊ฐœ์ˆ˜๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public long getNotificationsUnreadCount(@PathVariable(value = "user_id") Long userId) { + + long unreadCount = notificationRepository.countByUserIdAndIsReadFalse(userId); + + return unreadCount; + } + + // ์•Œ๋ฆผ ์ฝ์Œ ์ฒ˜๋ฆฌ + @PostMapping("{notification_id}/read/") + @Operation(summary = "์•Œ๋ฆผ ์ฝ์Œ ์ฒ˜๋ฆฌ", description = "์•Œ๋ฆผ์„ '์ฝ์Œ' ์ƒํƒœ๋กœ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค") + public void markAsRead(@PathVariable(value = "notification_id") Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new IllegalArgumentException("์ž˜๋ชป๋œ ์•Œ๋ฆผ ID์ž…๋‹ˆ๋‹ค.")); + notification.markRead(); // read ํ•„๋“œ๋ฅผ true๋กœ + notificationRepository.save(notification); + } + + // ์ง€๋„ ์ข‹์•„์š” ์•Œ๋ฆผ ์ƒ์„ฑ + @PostMapping("/radar/like/{receiverId}") + @Operation(summary = "์ง€๋„ ์ข‹์•„์š” ์•Œ๋ฆผ ์ƒ์„ฑ", description = "ํŠน์ • ์œ ์ €์˜ ๋ ˆ์ด๋” ์ƒ๋ฉ”์— ์ข‹์•„์š”๋ฅผ ๋ˆŒ๋ €๋‹ค๋Š” ์•Œ๋ฆผ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity notifyRadarLike(@PathVariable(value = "receiverId") Long receiverId) { + Long authenticatedUserId = PrincipalHandler.getUserId(); + NotificationResponseDto responseDto = notificationService.createRadarLikeNotification(authenticatedUserId, receiverId); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + } + +} + diff --git a/src/main/java/com/example/lionsforest/domain/notification/dto/response/NotificationResponseDto.java b/src/main/java/com/example/lionsforest/domain/notification/dto/response/NotificationResponseDto.java new file mode 100644 index 0000000..ea324f5 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/dto/response/NotificationResponseDto.java @@ -0,0 +1,33 @@ +package com.example.lionsforest.domain.notification.dto.response; + +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.notification.TargetType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class NotificationResponseDto { + private Long id; + private String content; + private String photo; + private Long targetId; + private TargetType targetType; + private boolean read; + private LocalDateTime createdAt; + + public static NotificationResponseDto fromEntity(Notification notification) { + return NotificationResponseDto.builder() + .id(notification.getId()) + .content(notification.getContent()) + .photo(notification.getPhoto()) + .targetId(notification.getTargetId()) + .targetType(notification.getTargetType()) + .read(notification.isRead()) + .createdAt(notification.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/notification/repository/NotificationRepository.java b/src/main/java/com/example/lionsforest/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..feff12f --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,30 @@ +package com.example.lionsforest.domain.notification.repository; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.user.User; +import jakarta.transaction.Transactional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface NotificationRepository extends JpaRepository { + // ํŠน์ • ์œ ์ €์˜ ๋ชจ๋“  ์•Œ๋ฆผ ์ตœ์‹ ์ˆœ ์กฐํšŒ + List findAllByUserIdOrderByCreatedAtDesc(Long userId); + + // ์•ˆ ์ฝ์€ ์•Œ๋ฆผ ๊ฐœ์ˆ˜ ์กฐํšŒ + long countByUserIdAndIsReadFalse(Long userId); + + // ๊ธฐ์ค€์‹œ๊ฐ„(cutoff) ์ด์ „ ์ƒ์„ฑ๋œ ๋ชจ๋“  ์•Œ๋ฆผ ์‚ญ์ œ + @Transactional + @Modifying + @Query("DELETE FROM Notification n WHERE n.createdAt < :cutoff") + int deleteAllByCreatedAtBefore(@Param("cutoff") LocalDateTime cutoff); + + boolean existsByUserAndContent(User user, String content); +} diff --git a/src/main/java/com/example/lionsforest/domain/notification/scheduler/NotificationScheduler.java b/src/main/java/com/example/lionsforest/domain/notification/scheduler/NotificationScheduler.java new file mode 100644 index 0000000..f752142 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/scheduler/NotificationScheduler.java @@ -0,0 +1,70 @@ +package com.example.lionsforest.domain.notification.scheduler; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.GroupPhoto; +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.group.repository.GroupPhotoRepository; +import com.example.lionsforest.domain.group.repository.GroupRepository; +import com.example.lionsforest.domain.group.repository.ParticipationRepository; +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.notification.TargetType; +import com.example.lionsforest.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class NotificationScheduler { + + private final GroupRepository groupRepository; + private final NotificationRepository notificationRepository; + private final ParticipationRepository participationRepository; + private final GroupPhotoRepository groupPhotoRepository; + + // 1๋ถ„๋งˆ๋‹ค ์‹คํ–‰๋˜๋Š” ์Šค์ผ€์ค„๋Ÿฌ (fixedRate = 60000ms) + @Scheduled(fixedRate = 60000) + public void notifyEventsStartingSoon() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime oneHourLater = now.plusHours(1).withSecond(0).withNano(0); + // 1์‹œ๊ฐ„ ํ›„ (ยฑ1๋ถ„ ์ด๋‚ด) ์‹œ์ž‘ํ•˜๋Š” ๋ชจ์ž„ ๋ชฉ๋ก ์กฐํšŒ + LocalDateTime startRange = oneHourLater.minusMinutes(1); + LocalDateTime endRange = oneHourLater.plusMinutes(1); + List upcomingGroups = groupRepository.findByMeetingAtBetween(startRange, endRange); + + for (Group group : upcomingGroups) { + + List participations = participationRepository.findByGroupId(group.getId()); + // ๋ชจ์ž„ ์ฒซ ์‚ฌ์ง„ ๊ฐ€์ ธ์˜ค๊ธฐ + String photoPath = null; + Optional firstPhotoOpt = groupPhotoRepository.findFirstByGroupIdOrderByPhotoOrderAsc(group.getId()); + if (firstPhotoOpt.isPresent()) { + photoPath = firstPhotoOpt.get().getPhoto(); + } + + String content = String.format("โฐ '[%s] %s' ๋ชจ์ž„ 1์‹œ๊ฐ„ ์ „์ž…๋‹ˆ๋‹ค!", + group.getMeetingAt().format(DateTimeFormatter.ofPattern("yy.MM.dd")), + group.getTitle()); + + for (Participation part : participations) { + // ์ค‘๋ณต ์•Œ๋ฆผ ์ƒ์„ฑ ์ฒดํฌ + if (!notificationRepository.existsByUserAndContent(part.getUser(), content)) { + Notification notification = Notification.builder() + .user(part.getUser()) + .content(content) + .photo(photoPath) + .targetId(group.getId()) + .targetType(TargetType.GROUP) + .build(); + notificationRepository.save(notification); + } + } + } + } +} + diff --git a/src/main/java/com/example/lionsforest/domain/notification/service/NotificationCleanupService.java b/src/main/java/com/example/lionsforest/domain/notification/service/NotificationCleanupService.java new file mode 100644 index 0000000..b8fc7df --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/service/NotificationCleanupService.java @@ -0,0 +1,26 @@ +package com.example.lionsforest.domain.notification.service; + +import com.example.lionsforest.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationCleanupService { + + private NotificationRepository notificationRepository; + + // ๋งค์ผ ์ƒˆ๋ฒฝ 2์‹œ์— ์‹คํ–‰ + @Scheduled(cron = "0 0 2 * * *") + public void cleanupOldNotifications() { + LocalDateTime cutoff = LocalDateTime.now().minusDays(30); + int deletedCount = notificationRepository.deleteAllByCreatedAtBefore(cutoff); + log.info("Deleted {} notifications older than 30 days", deletedCount); + } +} + diff --git a/src/main/java/com/example/lionsforest/domain/notification/service/NotificationService.java b/src/main/java/com/example/lionsforest/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..7431292 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/service/NotificationService.java @@ -0,0 +1,49 @@ +package com.example.lionsforest.domain.notification.service; + +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.notification.TargetType; +import com.example.lionsforest.domain.notification.dto.response.NotificationResponseDto; +import com.example.lionsforest.domain.notification.repository.NotificationRepository; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class NotificationService { + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + + // ๋ ˆ์ด๋” ์ข‹์•„์š” ์•Œ๋ฆผ ์ƒ์„ฑ + public NotificationResponseDto createRadarLikeNotification(Long senderId, Long receiverId) { + User sender = findUserById(senderId); + User receiver = findUserById(receiverId); + + String senderNickname = sender.getNickname(); + System.out.println("senderNickname: " + senderNickname); + String content = String.format("โ™ฅ\uFE0F ๋‚˜์˜ ์ƒํƒœ๋ฉ”์‹œ์ง€์— '%s'๋‹˜์ด ํ•˜ํŠธ๋ฅผ ๋‹ฌ์•˜์–ด์š”", senderNickname); + + Notification notification = Notification.builder() + .user(receiver) + .content(content) + .photo(receiver.getProfile_photo()) + .isRead(false) + .targetId(receiverId) + .targetType(TargetType.RADAR) + .build(); + notificationRepository.save(notification); + + return NotificationResponseDto.fromEntity(notification); + + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/radar/Radar.java b/src/main/java/com/example/lionsforest/domain/radar/Radar.java new file mode 100644 index 0000000..3d3b837 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/radar/Radar.java @@ -0,0 +1,43 @@ +package com.example.lionsforest.domain.radar; + +import com.example.lionsforest.domain.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Radar { + @Id //1:1 ๊ด€๊ณ„์ด๋ฏ€๋กœ user์˜ pk๋ฅผ ๊ทธ๋Œ€๋กœ pk๋กœ ์‚ฌ์šฉ + @Column(name = "user_id") + private Long user_id; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId //'userId' ํ•„๋“œ๊ฐ€ ์ด ๊ด€๊ณ„์— ๋งคํ•‘๋จ + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false) + private boolean enable; + + private Double latitude; + + private Double longitude; + + @Enumerated(EnumType.STRING) + private RadarState state; + + private String message; + + private LocalDateTime updated_at; + + private Integer likes; + +} diff --git a/src/main/java/com/example/lionsforest/domain/radar/RadarState.java b/src/main/java/com/example/lionsforest/domain/radar/RadarState.java new file mode 100644 index 0000000..dfb9c21 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/radar/RadarState.java @@ -0,0 +1,9 @@ +package com.example.lionsforest.domain.radar; + +public enum RadarState { + STUDYING, //๊ณต๋ถ€ ์ค‘ + EATING, //์‹์‚ฌ ์ค‘ + RESTING, //ํœด์‹ ์ค‘ + BORED, //์‹ฌ์‹ฌํ•ด์š” + HUNGRY //๋ฐฐ๊ณ ํŒŒ์š” +} diff --git a/src/main/java/com/example/lionsforest/domain/review/Review.java b/src/main/java/com/example/lionsforest/domain/review/Review.java new file mode 100644 index 0000000..314464b --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/Review.java @@ -0,0 +1,41 @@ +package com.example.lionsforest.domain.review; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Review extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="group_id", nullable = false) + private Group group; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private Integer score; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + //ํ›„๊ธฐ ์‚ฌ์ง„ + @Builder.Default + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL) + private List photos = new ArrayList<>(); +} diff --git a/src/main/java/com/example/lionsforest/domain/review/ReviewPhoto.java b/src/main/java/com/example/lionsforest/domain/review/ReviewPhoto.java new file mode 100644 index 0000000..ac41c24 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/ReviewPhoto.java @@ -0,0 +1,26 @@ +package com.example.lionsforest.domain.review; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ReviewPhoto { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; + + @Column(nullable = false) + private String photo; + + @Column(nullable = false) + private Integer photo_order; +} diff --git a/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java new file mode 100644 index 0000000..ca07b7a --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java @@ -0,0 +1,142 @@ +package com.example.lionsforest.domain.review.controller; + +import com.example.lionsforest.domain.comment.dto.request.CommentRequestDto; +import com.example.lionsforest.domain.group.dto.request.GroupRequestDto; +import com.example.lionsforest.domain.group.dto.request.GroupUpdateRequestDto; +import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; +import com.example.lionsforest.domain.review.dto.request.ReviewRequestDto; +import com.example.lionsforest.domain.review.dto.request.ReviewUpdateRequestDto; +import com.example.lionsforest.domain.review.dto.response.ReviewGetResponseDto; +import com.example.lionsforest.domain.review.dto.response.ReviewResponseDto; +import com.example.lionsforest.domain.review.service.ReviewService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/reviews/") +@Tag(name = "ํ›„๊ธฐ", description = "ํ›„๊ธฐ ๊ด€๋ จ API") +public class ReviewController { + private final ReviewService reviewService; + + // ํ›„๊ธฐ ์ƒ์„ฑ + @PostMapping(value = "{group_id}/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "ํ›„๊ธฐ ์ƒ์„ฑ", description = """ + ์š”์ฒญ ํ˜•์‹: multipart/form-data + - score : Integer + - content : string + - title: string + - photos: ์ด๋ฏธ์ง€ ํŒŒ์ผ ์—ฌ๋Ÿฌ ๊ฐœ (๋™์ผ ํ‚ค 'photos'๋กœ append) + + ### ๐Ÿ’ป ํ”„๋ก ํŠธ ์ „์†ก ์˜ˆ์‹œ (Axios) + ```javascript + const form = new FormData(); + form.append("score", "3"); + form.append("content", "ํ›„๊ธฐ ๋‚ด์šฉ"); + files.forEach(f => form.append("photos", f)); // ๋™์ผ ํ‚ค๋กœ ์—ฌ๋Ÿฌ ๋ฒˆ append + + await axios.post("/api/reviews/", form, { + headers: { "Content-Type": "multipart/form-data" } + }); + + """) + @io.swagger.v3.oas.annotations.parameters.RequestBody( // Swagger ๋ฌธ์„œํ™”์šฉ + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(implementation = ReviewRequestDto.class) + )) + public ResponseEntity createReview(@PathVariable("group_id") Long groupId, + @ModelAttribute ReviewRequestDto req, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + return ResponseEntity.ok(reviewService.createReview(groupId, req, loginUserId)); + } + + // ํ›„๊ธฐ ์ˆ˜์ • + @PatchMapping(value = "{review_id}/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "ํ›„๊ธฐ ์ˆ˜์ •", description = """ + ์š”์ฒญ ํ˜•์‹: multipart/form-data + - score : Integer + - content : string + - title: string + - deletePhotoIds : List ์‚ญ์ œํ•  ์‚ฌ์ง„์˜ ์•„์ด๋”” ๋ฆฌ์ŠคํŠธ + - photos: ์ด๋ฏธ์ง€ ํŒŒ์ผ ์—ฌ๋Ÿฌ ๊ฐœ (๋™์ผ ํ‚ค 'photos'๋กœ append) + + ### ๐Ÿ’ป ํ”„๋ก ํŠธ ์ „์†ก ์˜ˆ์‹œ (Axios) + ```javascript + const form = new FormData(); + form.append("score", "3"); + form.append("content", "ํ›„๊ธฐ ๋‚ด์šฉ"); + form.append("deletePhotoIds", JSON.stringify([2, 5])); + files.forEach(f => form.append("addPhotos", f)); // ๋™์ผ ํ‚ค๋กœ ์—ฌ๋Ÿฌ ๋ฒˆ append + + await axios.patch("/api/reviews/${review_id}/", form, { + headers: { "Content-Type": "multipart/form-data" } + }); + + """) + @io.swagger.v3.oas.annotations.parameters.RequestBody( // Swagger ๋ฌธ์„œํ™”์šฉ + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(implementation = ReviewUpdateRequestDto.class) + )) + public ResponseEntity updateReview(@PathVariable("review_id") Long reviewId, + @ModelAttribute ReviewUpdateRequestDto req, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + return ResponseEntity.ok(reviewService.updateReview(reviewId, req, loginUserId)); + } + + // ๊ฐœ๋ณ„ ํ›„๊ธฐ ์กฐํšŒ + @GetMapping("{review_id}/") + @Operation(summary = "๊ฐœ๋ณ„ ํ›„๊ธฐ ์กฐํšŒ", description = "ํŠน์ • ํ›„๊ธฐ(By review_id)๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity getReviewById(@PathVariable("review_id") Long reviewId){ + return ResponseEntity.ok(reviewService.getReviewById(reviewId)); + + } + + // ํ›„๊ธฐ ์ „์ฒด ์กฐํšŒ + @GetMapping + @Operation(summary = "ํ›„๊ธฐ ์ „์ฒด ์กฐํšŒ", description = "์ „์ฒด ํ›„๊ธฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity> getAllReview(){ + return ResponseEntity.ok(reviewService.getAllReview()); + } + + // ๋ชจ์ž„๋ณ„ ํ›„๊ธฐ ์กฐํšŒ + @GetMapping("by-group/{group_id}/") + @Operation(summary = "๋ชจ์ž„๋ณ„ ํ›„๊ธฐ ์กฐํšŒ", description = "ํŠน์ • ๋ชจ์ž„(By group_id)์— ๋Œ€ํ•œ ํ›„๊ธฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity> getReviewByGroupId(@PathVariable("group_id") Long groupId){ + return ResponseEntity.ok(reviewService.getReviewByGroupId(groupId)); + } + + // ํŠน์ • ์œ ์ €์˜ ํ›„๊ธฐ ์ „์ฒด ์กฐํšŒ + @GetMapping("by-user/{user_id}/") + @Operation(summary = "์œ ์ €๋ณ„ ํ›„๊ธฐ ์กฐํšŒ", description = "ํŠน์ • ์œ ์ €(By user_id)์— ๋Œ€ํ•œ ํ›„๊ธฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity> getReviewByUserId(@PathVariable("user_id") Long userId){ + return ResponseEntity.ok(reviewService.getReviewByUserId(userId)); + } + + // ํ›„๊ธฐ ์‚ญ์ œ + @DeleteMapping("{review_id}/") + @Operation(summary = "ํ›„๊ธฐ ์‚ญ์ œ", description = "ํŠน์ • ํ›„๊ธฐ(review_id)๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity deleteReview(@PathVariable("review_id") Long reviewId, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + + Long loginUserId = Long.valueOf(principal.getUsername()); + + reviewService.deleteReview(reviewId, loginUserId); + return ResponseEntity.ok("ํ›„๊ธฐ๊ฐ€ ์‚ญ์ œ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewRequestDto.java b/src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewRequestDto.java new file mode 100644 index 0000000..5a275b4 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewRequestDto.java @@ -0,0 +1,32 @@ +package com.example.lionsforest.domain.review.dto.request; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Data +@Getter +@NoArgsConstructor +@Schema(description = "ํ›„๊ธฐ ์ƒ์„ฑ ์š”์ฒญ") +public class ReviewRequestDto { + @Schema(description = "๋ณ„์ ") + @NotNull + private Integer score; + + @Schema(description = "ํ›„๊ธฐ ๋‚ด์šฉ") + @NotNull + private String content; + + @ArraySchema( + arraySchema = @Schema(description = "์—…๋กœ๋“œํ•  ์‚ฌ์ง„๋“ค"), + schema = @Schema(type = "string", format = "binary") + ) + private List photos; + +} diff --git a/src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewUpdateRequestDto.java b/src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewUpdateRequestDto.java new file mode 100644 index 0000000..e3fb535 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewUpdateRequestDto.java @@ -0,0 +1,30 @@ +package com.example.lionsforest.domain.review.dto.request; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Data +@Getter +@NoArgsConstructor +public class ReviewUpdateRequestDto { + @Schema(description = "๋ณ„์ ") + private Integer score; + + @Schema(description = "ํ›„๊ธฐ ๋‚ด์šฉ") + private String content; + + @Schema(description = "์‚ญ์ œํ•  ์‚ฌ์ง„์˜ ์•„์ด๋”” ๋ฆฌ์ŠคํŠธ") + private List deletePhotoIds; + + @ArraySchema( + arraySchema = @Schema(description = "์ถ”๊ฐ€ํ•  ์‚ฌ์ง„๋“ค"), + schema = @Schema(type = "string", format = "binary") + ) + private List addPhotos; +} diff --git a/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewGetResponseDto.java b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewGetResponseDto.java new file mode 100644 index 0000000..9288912 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewGetResponseDto.java @@ -0,0 +1,51 @@ +package com.example.lionsforest.domain.review.dto.response; + +import com.example.lionsforest.domain.review.Review; +import com.example.lionsforest.domain.review.ReviewPhoto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class ReviewGetResponseDto { + private Long id; + private Long groupId; + private Long userId; + private String userName; + private String userNickName; + private String profilePhotoUrl; + private String groupTitle; + private String content; + private Integer score; + private LocalDateTime createdAt; + + private List photos; + + public static ReviewGetResponseDto fromEntity(Review review){ + + List photos = review.getPhotos().stream() + .sorted(Comparator.comparing(ReviewPhoto::getPhoto_order)) + .map(ReviewPhotoDto::new) + .toList(); + + return ReviewGetResponseDto.builder() + .id(review.getId()) + .groupId(review.getGroup().getId()) + .userId(review.getUser().getId()) + .userName(review.getUser().getName()) + .userNickName(review.getUser().getNickname()) + .profilePhotoUrl(review.getUser().getProfile_photo()) + .groupTitle(review.getGroup().getTitle()) + .content(review.getContent()) + .score(review.getScore()) + .createdAt(review.getCreatedAt()) + .photos(photos) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewPhotoDto.java b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewPhotoDto.java new file mode 100644 index 0000000..dc5897f --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewPhotoDto.java @@ -0,0 +1,17 @@ +package com.example.lionsforest.domain.review.dto.response; + +import com.example.lionsforest.domain.review.ReviewPhoto; +import lombok.Getter; + +@Getter +public class ReviewPhotoDto { + private final Long id; + private final String photoUrl; + private final Integer order; + + public ReviewPhotoDto(ReviewPhoto photo) { + this.id = photo.getId(); + this.photoUrl = photo.getPhoto(); + this.order = photo.getPhoto_order(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewResponseDto.java b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewResponseDto.java new file mode 100644 index 0000000..199c8af --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewResponseDto.java @@ -0,0 +1,33 @@ +package com.example.lionsforest.domain.review.dto.response; + +import com.example.lionsforest.domain.comment.Comment; +import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; +import com.example.lionsforest.domain.review.Review; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class ReviewResponseDto { + private Long id; + private Long groupId; + private Long userId; + private String content; + private Integer score; + private LocalDateTime createdAt; + + public static ReviewResponseDto fromEntity(Review review){ + return ReviewResponseDto.builder() + .id(review.getId()) + .groupId(review.getGroup().getId()) + .userId(review.getUser().getId()) + .content(review.getContent()) + .score(review.getScore()) + .createdAt(review.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/review/repository/ReviewPhotoRepository.java b/src/main/java/com/example/lionsforest/domain/review/repository/ReviewPhotoRepository.java new file mode 100644 index 0000000..139984c --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/repository/ReviewPhotoRepository.java @@ -0,0 +1,12 @@ +package com.example.lionsforest.domain.review.repository; + +import com.example.lionsforest.domain.review.Review; +import com.example.lionsforest.domain.review.ReviewPhoto; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReviewPhotoRepository extends JpaRepository { + List findAllByReview(Review review); + int countByReviewId(Long reviewId); +} diff --git a/src/main/java/com/example/lionsforest/domain/review/repository/ReviewRepository.java b/src/main/java/com/example/lionsforest/domain/review/repository/ReviewRepository.java new file mode 100644 index 0000000..84ebfc0 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/repository/ReviewRepository.java @@ -0,0 +1,14 @@ +package com.example.lionsforest.domain.review.repository; + +import com.example.lionsforest.domain.review.Review; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ReviewRepository extends JpaRepository { + Optional findByGroupIdAndUserId(Long groupId, Long userId); + + List findByGroupId(Long groupId); + List findByUserId(Long userId); +} diff --git a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java new file mode 100644 index 0000000..65ebdea --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -0,0 +1,264 @@ +package com.example.lionsforest.domain.review.service; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.GroupPhoto; +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.group.repository.GroupPhotoRepository; +import com.example.lionsforest.domain.group.repository.GroupRepository; +import com.example.lionsforest.domain.group.repository.ParticipationRepository; +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.notification.TargetType; +import com.example.lionsforest.domain.notification.repository.NotificationRepository; +import com.example.lionsforest.domain.review.Review; +import com.example.lionsforest.domain.review.ReviewPhoto; +import com.example.lionsforest.domain.review.dto.request.ReviewRequestDto; +import com.example.lionsforest.domain.review.dto.request.ReviewUpdateRequestDto; +import com.example.lionsforest.domain.review.dto.response.ReviewGetResponseDto; +import com.example.lionsforest.domain.review.dto.response.ReviewResponseDto; +import com.example.lionsforest.domain.review.repository.ReviewPhotoRepository; +import com.example.lionsforest.domain.review.repository.ReviewRepository; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.common.S3UploadService; + +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ReviewService { + private final ReviewRepository reviewRepository; + private final ReviewPhotoRepository reviewPhotoRepository; + private final ParticipationRepository participationRepository; + private final GroupRepository groupRepository; + private final UserRepository userRepository; + private final S3UploadService s3UploadService; + private final GroupPhotoRepository groupPhotoRepository; + private final NotificationRepository notificationRepository; + + // ํ›„๊ธฐ ์ƒ์„ฑ + @Transactional + public ReviewResponseDto createReview(Long groupId, + ReviewRequestDto dto, + Long userId){ + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!participationRepository.existsByGroupIdAndUserId(groupId, userId)) { + throw new BusinessException(ErrorCode.GROUP_PARTICIPATION_NOT_FOUND); + } + + Review review = Review.builder() + .group(group) + .score(dto.getScore()) + .content(dto.getContent()) + .user(user) + .build(); + + Review saved = reviewRepository.save(review); + + List photos = dto.getPhotos(); + if (photos != null && !photos.isEmpty()) { + List reviewPhotos = new ArrayList<>(); + for (int i = 0; i < photos.size(); i++) { + MultipartFile photo = photos.get(i); + + // S3(๋˜๋Š” ๋กœ์ปฌ)์— ํŒŒ์ผ ์—…๋กœ๋“œ -> URL ๋ฐ˜ํ™˜ + String photoUrl = s3UploadService.upload(photo, "review-photos"); + // ReviewPhoto ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ + ReviewPhoto reviewPhoto = ReviewPhoto.builder() + .review(saved) // ์ €์žฅ๋œ Review ๊ฐ์ฒด + .photo(photoUrl) // S3์—์„œ ๋ฐ˜ํ™˜๋œ URL + .photo_order(i) // ์‚ฌ์ง„ ์ˆœ์„œ (0๋ถ€ํ„ฐ ์‹œ์ž‘) + .build(); + + reviewPhotos.add(reviewPhoto); + } + // ReviewPhoto ๋ฆฌ์ŠคํŠธ๋ฅผ DB์— ํ•œ ๋ฒˆ์— ์ €์žฅ (Batch Insert) + reviewPhotoRepository.saveAll(reviewPhotos); + } + + // ์•Œ๋ฆผ ์ƒ์„ฑ: ๋‹ค๋ฅธ ๋ชจ์ž„์›๋“ค์—๊ฒŒ ํ›„๊ธฐ ์ž‘์„ฑ ์•Œ๋ฆผ ๋ณด๋‚ด๊ธฐ + String dateStr = group.getMeetingAt().format(DateTimeFormatter.ofPattern("yy.MM.dd")); + String content = "๐Ÿ™Œ '" + (user.getNickname() != null ? user.getNickname() : user.getName()) + + "'๋‹˜์ด [" + dateStr + "] " + group.getTitle() + " ๋ชจ์ž„์— ๋ชจ์ž„ ํ›„๊ธฐ๋ฅผ ์ž‘์„ฑํ–ˆ์–ด์š”."; + // ๋ชจ์ž„ ์ฒซ ์‚ฌ์ง„ ๊ฒฝ๋กœ + String photoPath = null; + Optional firstPhotoOpt = groupPhotoRepository.findFirstByGroupIdOrderByPhotoOrderAsc(groupId); + if (firstPhotoOpt.isPresent()) { + photoPath = firstPhotoOpt.get().getPhoto(); + } + // ๋ชจ์ž„์— ์ฐธ์—ฌํ•œ ๋ชจ๋“  ์‚ฌ์šฉ์ž์—๊ฒŒ ์•Œ๋ฆผ (์ž‘์„ฑ์ž ๋ณธ์ธ ์ œ์™ธ) + List participations = participationRepository.findByGroupId(groupId); + for (Participation part : participations) { + if (!part.getUser().getId().equals(userId)) { + Notification notification = Notification.builder() + .user(part.getUser()) + .content(content) + .photo(photoPath) + .targetId(groupId) + .targetType(TargetType.GROUP) + .build(); + notificationRepository.save(notification); + } + } + + return ReviewResponseDto.fromEntity(saved); + } + + // ํ›„๊ธฐ ์‚ญ์ œ + @Transactional + public void deleteReview(Long reviewId, Long userId){ + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Review review = reviewRepository + .findById(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + + if(!review.getUser().getId().equals(userId)) { + throw new BusinessException(ErrorCode.REVIEW_PERMISSION_DENIED); + } + + if (review.getPhotos() != null) { + review.getPhotos().forEach(p -> s3UploadService.delete(p.getPhoto())); + } + + reviewRepository.delete(review); + } + + //ํ›„๊ธฐ ์ˆ˜์ • + @Transactional + public ReviewResponseDto updateReview(Long reviewId, + ReviewUpdateRequestDto dto, + Long userId){ + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + + if (!review.getUser().getId().equals(userId)) { + throw new BusinessException(ErrorCode.REVIEW_PERMISSION_DENIED); + } + + if (dto.getContent() != null) { + review.setContent(dto.getContent()); + } + if (dto.getScore() != null) { + review.setScore(dto.getScore()); + } + + // ์‚ฌ์ง„ ์‚ญ์ œ ์š”์ฒญ ์ฒ˜๋ฆฌ (S3/๋กœ์ปฌ โ†’ ํŒŒ์ผ ๋จผ์ € ์‚ญ์ œ, ๊ทธ ๋‹ค์Œ DB ์‚ญ์ œ) + if (dto.getDeletePhotoIds() != null && !dto.getDeletePhotoIds().isEmpty()) { + List toDelete = review.getPhotos().stream() + .filter(p -> dto.getDeletePhotoIds().contains(p.getId())) + .toList(); + + // ์†Œ์œ  ๊ฒ€์ฆ: ๋‹ค๋ฅธ ํ›„๊ธฐ ์‚ฌ์ง„์„ ์‚ญ์ œํ•˜๋ ค๋Š” ๊ฒฝ์šฐ ์ฐจ๋‹จ + if (toDelete.size() != dto.getDeletePhotoIds().size()) { + throw new IllegalArgumentException("์‚ญ์ œ ๋Œ€์ƒ์— ํฌํ•จ๋œ ์‚ฌ์ง„ ์ค‘ ์ด ํ›„๊ธฐ์˜ ์‚ฌ์ง„์ด ์•„๋‹Œ ํ•ญ๋ชฉ์ด ์žˆ์Šต๋‹ˆ๋‹ค."); + } + + // ์Šคํ† ๋ฆฌ์ง€ ํŒŒ์ผ ์‚ญ์ œ + toDelete.forEach(p -> s3UploadService.delete(p.getPhoto())); + + // DB ์‚ญ์ œ + reviewPhotoRepository.deleteAllInBatch(toDelete); + + // ์ปฌ๋ ‰์…˜ ๋™๊ธฐํ™”(์„ ํƒ): ์˜์† ์ปฌ๋ ‰์…˜์—์„œ๋„ ์ œ๊ฑฐ + review.getPhotos().removeAll(toDelete); + } + + List addPhotos = dto.getAddPhotos(); + // ์‚ฌ์ง„ ์ถ”๊ฐ€ + if (addPhotos != null && !addPhotos.isEmpty()) { + // ํ˜„์žฌ ์ตœ๋Œ€ photo_order ๊ณ„์‚ฐ + int nextOrder = review.getPhotos().stream() + .map(ReviewPhoto::getPhoto_order) + .max(Integer::compareTo) + .orElse(-1) + 1; + + List toAdd = new ArrayList<>(); + for (int i = 0; i < addPhotos.size(); i++) { + MultipartFile file = addPhotos.get(i); + String url = s3UploadService.upload(file, "review-photos"); + + ReviewPhoto rp = ReviewPhoto.builder() + .review(review) + .photo(url) + .photo_order(nextOrder + i) + .build(); + toAdd.add(rp); + } + reviewPhotoRepository.saveAll(toAdd); + + // ์ปฌ๋ ‰์…˜์—๋„ ์ถ”๊ฐ€(์–‘๋ฐฉํ–ฅ ์ปฌ๋ ‰์…˜ ์œ ์ง€) + review.getPhotos().addAll(toAdd); + } + + // photo_order ์ •๊ทœํ™” + normalizePhotoOrders(review); + + return ReviewResponseDto.fromEntity(review); + } + + + // ๊ฐœ๋ณ„ ํ›„๊ธฐ ์กฐํšŒ + @Transactional(readOnly = true) + public ReviewGetResponseDto getReviewById(Long reviewId){ + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + return ReviewGetResponseDto.fromEntity(review); + } + + // ํ›„๊ธฐ ์ „์ฒด ์กฐํšŒ + @Transactional(readOnly = true) + public List getAllReview(){ + List reviews = reviewRepository.findAll(); + + return reviews.stream() + .map(ReviewGetResponseDto::fromEntity) + .toList(); + } + + + // ๋ชจ์ž„๋ณ„ ํ›„๊ธฐ ์ „์ฒด ์กฐํšŒ + @Transactional(readOnly = true) + public List getReviewByGroupId(Long groupId){ + List reviews = reviewRepository.findByGroupId(groupId); + + return reviews.stream() + .map(ReviewGetResponseDto::fromEntity) + .toList(); + } + + // ํŠน์ • ์œ ์ €์˜ ํ›„๊ธฐ ์ „์ฒด ์กฐํšŒ + @Transactional(readOnly = true) + public List getReviewByUserId(Long userId){ + List reviews = reviewRepository.findByUserId(userId); + + return reviews.stream() + .map(ReviewGetResponseDto::fromEntity) + .toList(); + } + + private void normalizePhotoOrders(Review review) { + review.getPhotos().stream() + .sorted((a, b) -> Integer.compare(a.getPhoto_order(), b.getPhoto_order())) + .forEachOrdered(new java.util.function.Consumer() { + int idx = 0; + @Override public void accept(ReviewPhoto p) { p.setPhoto_order(idx++); } + }); + } + + +} diff --git a/src/main/java/com/example/lionsforest/domain/user/User.java b/src/main/java/com/example/lionsforest/domain/user/User.java new file mode 100644 index 0000000..6f815c7 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/User.java @@ -0,0 +1,100 @@ +package com.example.lionsforest.domain.user; + +import com.example.lionsforest.domain.comment.Comment; +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.notification.Notification; +import com.example.lionsforest.domain.radar.Radar; +import com.example.lionsforest.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "`User`") +public class User extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 15) + private String name; + + @Column(nullable = false, length = 63) + private String email; + + @Column(length = 10) + private String nickname; + + private String bio; + + private String profile_photo; + + //์—ฐ๊ด€๊ด€๊ณ„ + //๋‚ด๊ฐ€ ๊ฐœ์„คํ•œ ๋ชจ์ž„ + @Builder.Default + @OneToMany(mappedBy = "leader") + private List led_groups = new ArrayList<>(); + + //๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ๋ชจ์ž„ + @Builder.Default + @OneToMany(mappedBy = "user") + private List participations = new ArrayList<>(); + + //๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ๋Œ“๊ธ€ + @Builder.Default + @OneToMany(mappedBy = "user") + private List comments = new ArrayList<>(); + + //๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ๋Œ“๊ธ€ + @Builder.Default + @ManyToMany + @JoinTable( + name = "CommentLike", //์—ฐ๊ฒฐ ํ…Œ์ด๋ธ” ์ด๋ฆ„ + joinColumns = @JoinColumn(name = "user_id"), //๋‚ด FK + inverseJoinColumns = @JoinColumn(name = "comment_id") //์ƒ๋Œ€ ํ…Œ์ด๋ธ” FK + ) + private Set liked_comments = new HashSet<>(); + + //๋‚ด ๋ ˆ์ด๋” + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL) + private Radar radar; + + //๋‚ด ์•Œ๋ฆผ + @Builder.Default + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List notifications = new ArrayList<>(); + + //๋‚ด๊ฐ€ ์ค€ ๋ ˆ์ด๋” ์ข‹์•„์š” + @Builder.Default + @ManyToMany + @JoinTable( + name = "MessageLike", + joinColumns = @JoinColumn(name = "sender_id"), + inverseJoinColumns = @JoinColumn(name = "reciever_id") + ) + private Set liked_users = new HashSet<>(); + + //๋‚ด๊ฐ€ ๋ฐ›์€ ๋ ˆ์ด๋” ์ข‹์•„์š” + @Builder.Default + @ManyToMany(mappedBy = "liked_users") + private Set liked_by_users = new HashSet<>(); + + //๋ฉ”์„œ๋“œ + // ์œ ์ € ํ”„๋กœํ•„ ์ˆ˜์ • + // ๋‹‰๋„ค์ž„ & ๋ฐ”์ด์˜ค - ํ•ญ์ƒ ์—…๋ฐ์ดํŠธ + public void updateNicknameAndBio(String nickname, String bio) { + this.nickname = nickname; + this.bio = bio; + } + //profile_photo ์—…๋ฐ์ดํŠธ : @Setter์˜ setProfile_photo() ์‚ฌ์šฉ +} diff --git a/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java b/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java new file mode 100644 index 0000000..da143f8 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java @@ -0,0 +1,33 @@ +package com.example.lionsforest.domain.user.controller; + +import com.example.lionsforest.domain.user.dto.request.LoginRequestDTO; +import com.example.lionsforest.domain.user.dto.response.LoginResponseDTO; +import com.example.lionsforest.domain.user.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +@Tag(name = "์œ ์ €", description = "์œ ์ € ๋กœ๊ทธ์ธ ๊ด€๋ จ API") +public class AuthController { + + private final AuthService authService; + + //๊ตฌ๊ธ€ ๋กœ๊ทธ์ธ(ํšŒ์›๊ฐ€์ž…) + @PostMapping("/google") + @Operation(summary = "์œ ์ € ํšŒ์›๊ฐ€์ž… ๋ฐ ๋กœ๊ทธ์ธ", description = "firebase token์œผ๋กœ ์œ ์ € ๋กœ๊ทธ์ธ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity googleLogin( + @Valid @RequestBody LoginRequestDTO request) { + + LoginResponseDTO response = authService.loginWithGoogleCode(request); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java b/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java new file mode 100644 index 0000000..cfc4722 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java @@ -0,0 +1,58 @@ +package com.example.lionsforest.domain.user.controller; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.dto.response.TokenResponseDTO; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.jwt.JwtTokenProvider; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "์œ ์ €", description = "์œ ์ € ๋กœ๊ทธ์ธ ๊ด€๋ จ API") +public class TempAuthController { //ํ”„๋ก ํŠธ ํ…Œ์ŠคํŠธ์šฉ ์ž„์‹œ ์ปจํŠธ๋กค๋Ÿฌ + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + + // ์ƒ์„ฑ์ž ์ฃผ์ž… + public TempAuthController(UserRepository userRepository, JwtTokenProvider jwtTokenProvider) { + this.userRepository = userRepository; + this.jwtTokenProvider = jwtTokenProvider; + } + + + @GetMapping("/auth/test-token") // SecurityConfig์—์„œ /auth/** ๋Š” permitAll ์ด๋ผ ์ ‘๊ทผ ๊ฐ€๋Šฅ + @Operation(summary = "์ž„์‹œ ๋กœ๊ทธ์ธ", description = "์ž„์‹œ๋กœ ์œ ์ € ๋กœ๊ทธ์ธ์„ ์ฒ˜๋ฆฌํ•˜๊ณ  ์•ก์„ธ์Šค ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity getTestToken( + @RequestParam(defaultValue = "test@example.com") String email, + @RequestParam(defaultValue = "ํ…Œ์ŠคํŠธ์œ ์ €") String name + ) { + // 1. ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์˜ ๋กœ์ง๊ณผ ๋™์ผ: ์œ ์ € ์กฐํšŒ ๋˜๋Š” ์ƒ์„ฑ + User user = userRepository.findByEmail(email) + .orElseGet(() -> { + // User ์—”ํ‹ฐํ‹ฐ ๋นŒ๋”์— ๋งž๊ฒŒ ์ˆ˜์ •ํ•˜์„ธ์š” + User newUser = User.builder() + .email(email) + .name(name) + .bio("") + .nickname("") + .profile_photo(null) + .build(); + return userRepository.save(newUser); + }); + + // 2. ํ† ํฐ ์ƒ์„ฑ + TokenResponseDTO tokens = jwtTokenProvider.createTokens(user.getId(), user.getEmail()); + + // 3. ํ† ํฐ์„ JSON ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ + return ResponseEntity.ok(tokens); + } + + @GetMapping("/api/health") + public String health() { + return "OK"; + } +} diff --git a/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java new file mode 100644 index 0000000..ab34c4a --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java @@ -0,0 +1,80 @@ +package com.example.lionsforest.domain.user.controller; + + +import com.example.lionsforest.domain.user.dto.response.NicknameResponseDTO; +import com.example.lionsforest.domain.user.dto.response.UserInfoResponseDTO; +import com.example.lionsforest.domain.user.dto.request.UserUpdateRequestDTO; +import com.example.lionsforest.domain.user.service.NicknameService; +import com.example.lionsforest.domain.user.service.UserService; +import com.example.lionsforest.global.config.PrincipalHandler; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +@Tag(name = "์œ ์ €", description = "์œ ์ € ๊ด€๋ จ API") +public class UserController { + + private final UserService userService; + private final NicknameService nicknameService; + + //์œ ์ € ๋ชฉ๋ก ์กฐํšŒ + @GetMapping + @Operation(summary = "์œ ์ € ๋ชฉ๋ก ์กฐํšŒ", description = "์ „์ฒด ์œ ์ € ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity> getUserList() { + return ResponseEntity.ok(userService.getAllUsers()); + } + + //์œ ์ € ์ƒ์„ธ ์กฐํšŒ + @GetMapping("/{userId}") + @Operation(summary = "์œ ์ € ์ƒ์„ธ ์กฐํšŒ", description = "ํŠน์ • ์œ ์ €์˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค(By user_id)") + public ResponseEntity getUserDetail(@PathVariable Long userId) { + return ResponseEntity.ok(userService.getUserInfo(userId)); + } + + //๋‚ด ์ •๋ณด ์กฐํšŒ + @GetMapping("/me") + @Operation(summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", description = "๋งˆ์ดํŽ˜์ด์ง€์—์„œ ๋‚ด ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity getMyInfo() { + Long authenticatedUserId = PrincipalHandler.getUserId(); + UserInfoResponseDTO response = userService.getUserInfo(authenticatedUserId); + return ResponseEntity.ok(response); + } + + //๋‚ด ์ •๋ณด ์ˆ˜์ • + @PatchMapping(value = "/me", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "๋‚ด ์ •๋ณด ์ˆ˜์ •", description = "๋งˆ์ดํŽ˜์ด์ง€์—์„œ ๋‚ด ์œ ์ € ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค") + @RequestBody( + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(implementation = UserUpdateRequestDTO.class) + ) + ) + public ResponseEntity updateUser( + @ModelAttribute @Valid UserUpdateRequestDTO request) { + Long authenticatedUserId = PrincipalHandler.getUserId(); + + UserInfoResponseDTO updatedUser = userService.updateUserInfo(authenticatedUserId, request); + return ResponseEntity.ok(updatedUser); + } + + //๋žœ๋ค ๋‹‰๋„ค์ž„ ์ƒ์„ฑ + @GetMapping("/me/random-nickname") + @Operation(summary = "๋žœ๋ค ๋‹‰๋„ค์ž„ ์ƒ์„ฑ", description = "๋งˆ์ดํŽ˜์ด์ง€์—์„œ ๋žœ๋ค ๋‹‰๋„ค์ž„์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค") + public ResponseEntity createNickname(){ + Long authenticatedUserId = PrincipalHandler.getUserId(); + NicknameResponseDTO createdNickname = nicknameService.updateRandomNickname(authenticatedUserId); + return ResponseEntity.ok(createdNickname); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/request/LoginRequestDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/request/LoginRequestDTO.java new file mode 100644 index 0000000..964a9d9 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/request/LoginRequestDTO.java @@ -0,0 +1,9 @@ +package com.example.lionsforest.domain.user.dto.request; + +import lombok.Getter; + +@Getter +public class LoginRequestDTO { + private String code; + private String redirectUri; +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/request/UserInfoRequestDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/request/UserInfoRequestDTO.java new file mode 100644 index 0000000..d114739 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/request/UserInfoRequestDTO.java @@ -0,0 +1,25 @@ +package com.example.lionsforest.domain.user.dto.request; + +import com.example.lionsforest.domain.user.User; +import lombok.Builder; +import lombok.Getter; + +// ์œ ์ € ๋กœ๊ทธ์ธ์— ์‚ฌ์šฉ +// GoogleTokenVerifier๊ฐ€ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋‚ด๋ถ€ ์ „์šฉ DTO +@Getter +@Builder +public class UserInfoRequestDTO { + private String name; + private String email; + private String profile_photo; + + + // ์ตœ์ดˆ ๋กœ๊ทธ์ธ(ํšŒ์›๊ฐ€์ž…) ์‹œ ์‚ฌ์šฉ + public User toEntity(){ + return User.builder() + .name(this.name) + .email(this.email) + .profile_photo(this.profile_photo) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/request/UserUpdateRequestDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/request/UserUpdateRequestDTO.java new file mode 100644 index 0000000..ed4fd3d --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/request/UserUpdateRequestDTO.java @@ -0,0 +1,24 @@ +package com.example.lionsforest.domain.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +// ์œ ์ € ์ •๋ณด ์ˆ˜์ •์— ์‚ฌ์šฉ +@Getter +@Setter +public class UserUpdateRequestDTO { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "์ƒˆ๋กœ์šด_๋‹‰๋„ค์ž„") + private String nickname; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "ํ•œ ์ค„ ์†Œ๊ฐœ") + private String bio; + + private MultipartFile photo; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean removePhoto; +} diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/response/LoginResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/LoginResponseDTO.java new file mode 100644 index 0000000..6200b4d --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/LoginResponseDTO.java @@ -0,0 +1,16 @@ +package com.example.lionsforest.domain.user.dto.response; + +import lombok.Builder; +import lombok.Getter; + +//์œ ์ € ๋กœ๊ทธ์ธ ์‘๋‹ต์— ์‚ฌ์šฉ +@Builder +@Getter +public class LoginResponseDTO { + private Long id; + private String accessToken; + private String refreshToken; + private boolean isNewUser; //์ตœ์ดˆ ๊ฐ€์ž… ์—ฌ๋ถ€ + private String nickname; + private String firebaseToken; +} diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/response/NicknameResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/NicknameResponseDTO.java new file mode 100644 index 0000000..eb420f0 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/NicknameResponseDTO.java @@ -0,0 +1,10 @@ +package com.example.lionsforest.domain.user.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class NicknameResponseDTO { + String nickname; +} diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/response/TokenResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/TokenResponseDTO.java new file mode 100644 index 0000000..b00fab3 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/TokenResponseDTO.java @@ -0,0 +1,12 @@ +package com.example.lionsforest.domain.user.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class TokenResponseDTO { + private String grantType; + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/response/UserInfoResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/UserInfoResponseDTO.java new file mode 100644 index 0000000..ace9b6c --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/UserInfoResponseDTO.java @@ -0,0 +1,28 @@ +package com.example.lionsforest.domain.user.dto.response; + +import com.example.lionsforest.domain.user.User; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserInfoResponseDTO { + private Long id; + private String name; + private String email; + private String nickname; + private String bio; + private String profile_photo; + + // User ์—”ํ‹ฐํ‹ฐ๋ฅผ InfoResponse DTO๋กœ ๋ณ€ํ™˜ + public static UserInfoResponseDTO from(User user) { + return UserInfoResponseDTO.builder() + .id(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .bio(user.getBio()) + .profile_photo(user.getProfile_photo()) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/response/UserProfileResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/UserProfileResponseDTO.java new file mode 100644 index 0000000..f4d731d --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/UserProfileResponseDTO.java @@ -0,0 +1,24 @@ +package com.example.lionsforest.domain.user.dto.response; + +import com.example.lionsforest.domain.user.User; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserProfileResponseDTO { + private Long id; + private String nickname; + private String bio; + private String profile_photo; + + // User ์—”ํ‹ฐํ‹ฐ๋ฅผ InfoResponse DTO๋กœ ๋ณ€ํ™˜ + public static UserInfoResponseDTO from(User user) { + return UserInfoResponseDTO.builder() + .id(user.getId()) + .nickname(user.getNickname()) + .bio(user.getBio()) + .profile_photo(user.getProfile_photo()) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/user/repository/UserRepository.java b/src/main/java/com/example/lionsforest/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..3ae0192 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.example.lionsforest.domain.user.repository; + +import com.example.lionsforest.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + // ์ด๋ฉ”์ผ๋กœ ์‚ฌ์šฉ์ž ์ฐพ๊ธฐ - oauth ๋กœ๊ทธ์ธ์— ์‚ฌ์šฉ + Optional findByEmail(String email); + + // ๋‹‰๋„ค์ž„ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ - ๋‹‰๋„ค์ž„ ์ˆ˜์ • ์‹œ ์‚ฌ์šฉ(์ค‘๋ณต๊ฒ€์‚ฌ) + boolean existsByNickname(String nickname); +} diff --git a/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java new file mode 100644 index 0000000..2338874 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java @@ -0,0 +1,107 @@ +package com.example.lionsforest.domain.user.service; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.dto.request.LoginRequestDTO; +import com.example.lionsforest.domain.user.dto.response.LoginResponseDTO; +import com.example.lionsforest.domain.user.dto.response.TokenResponseDTO; +import com.example.lionsforest.domain.user.dto.request.UserInfoRequestDTO; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.component.FirebaseTokenVerifier; +import com.example.lionsforest.global.component.GoogleOAuthService; +import com.example.lionsforest.global.component.GoogleTokenVerifier; +import com.example.lionsforest.global.component.MemberWhitelistValidator; +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import com.example.lionsforest.global.jwt.JwtTokenProvider; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.naming.AuthenticationException; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final UserRepository userRepository; + private final MemberWhitelistValidator whitelistValidator; + private final JwtTokenProvider jwtTokenProvider; + private final GoogleTokenVerifier googleTokenVerifier; + private final NicknameService nicknameService; + private final GoogleOAuthService googleOAuthService; + + /*@Value("${google.auth.client-id}") + private String clientId; + + @Value("${google.auth.client-secret}") + private String clientSecret; + + @Value("${google.auth.redirect-uri}") + private String redirectUri;*/ + + //code๋ฅผ ๋ฐ›์•„ ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฉ”์†Œ๋“œ(๋ฉ”์ธ) + public LoginResponseDTO loginWithGoogleCode(LoginRequestDTO loginRequestDTO) { + UserInfoRequestDTO userInfo = googleOAuthService.getUserInfo(loginRequestDTO); + + return processUserLogin(userInfo); + } + + public LoginResponseDTO processUserLogin(UserInfoRequestDTO userInfo) { + + String name = userInfo.getName(); + String email = userInfo.getEmail(); + + //๋™์•„๋ฆฌ ๋ถ€์›์ธ์ง€ ์กฐํšŒ + if (!whitelistValidator.isMember(name, email)) { + throw new BusinessException(ErrorCode.USER_NOT_IN_WHITELIST); + } + + //์ฒซ ๊ฐ€์ž…์ธ์ง€ ํ™•์ธํ•œ ํ›„ ๋กœ๊ทธ์ธ ์‹œํ‚ด + Optional optionalUser = userRepository.findByEmail(email); + boolean isNewUser = false; + User user; + + if (optionalUser.isEmpty()) { + user = userInfo.toEntity(); + String userNickname = nicknameService.generateRandomNickname(""); + user.setNickname(userNickname); + userRepository.save(user); + isNewUser = true; + log.info("์ƒˆ ์œ ์ € ์ƒ์„ฑ!"); + } else { + user = optionalUser.get(); + log.info("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์œ ์ €"); + } + + // Firebase ์ปค์Šคํ…€ ํ† ํฐ ์ƒ์„ฑ + String firebaseToken; + try{ + //์šฐ๋ฆฌ DB์˜ userid๋ฅผ firebase์˜ uid๋กœ ์‚ฌ์šฉ + String uid = String.valueOf(user.getId()); + firebaseToken = FirebaseAuth.getInstance().createCustomToken(uid); + }catch(FirebaseAuthException e){ + log.error("Firebase ์ปค์Šคํ…€ ํ† ํฐ ์ƒ์„ฑ ์‹คํŒจ(User ID: {}): {}", user.getId(), e.getMessage()); + throw new BusinessException(ErrorCode.FIREBASE_TOKEN_CREATION_FAILED); + } + + // JWT ํ† ํฐ ์ƒ์„ฑ (๋ฐ˜ํ™˜ ํƒ€์ž…์ด TokenResponse DTO์ž„) + TokenResponseDTO tokens = jwtTokenProvider.createTokens(user.getId(), user.getEmail()); + + // ์‘๋‹ต DTO ์ƒ์„ฑ + return LoginResponseDTO.builder() + .id(user.getId()) + .accessToken(tokens.getAccessToken()) // TokenResponse DTO์˜ getter + .refreshToken(tokens.getRefreshToken()) // TokenResponse DTO์˜ getter + .isNewUser(isNewUser) + .nickname(user.getNickname()) + .firebaseToken(firebaseToken) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/domain/user/service/NicknameService.java b/src/main/java/com/example/lionsforest/domain/user/service/NicknameService.java new file mode 100644 index 0000000..ac39ccd --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/service/NicknameService.java @@ -0,0 +1,91 @@ +package com.example.lionsforest.domain.user.service; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.dto.response.NicknameResponseDTO; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Random; + +@Service +@RequiredArgsConstructor +public class NicknameService { + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + private final Random random = new Random(); + + //๋‹‰๋„ค์ž„ ๊ตฌ์„ฑ ํ›„๋ณด ํŒŒ์ผ(json) ๋กœ๋“œ + @Value("classpath:nickname-components.json") + private Resource nicknameResource; + + //ํŒŒ์ผ์—์„œ ์ฝ์–ด์˜จ ํ˜•์šฉ์‚ฌ, ๋ช…์‚ฌ ๋ฆฌ์ŠคํŠธ ์ €์žฅ + private List adjectives; + private List nouns; + + private record NicknameData( + List adj, + List noun + ){} + + //์„œ๋ฒ„ ์‹œ์ž‘ํ•  ๋•Œ ํ•œ ๋ฒˆ๋งŒ ์‹คํ–‰๋˜๋Š” ๋กœ์ง - ํŒŒ์ผ ์ฝ์–ด์™€์„œ ์ €์žฅํ•จ + @PostConstruct + public void loadNicknameComponents() { + try(InputStream inputStream = nicknameResource.getInputStream()){ + NicknameData data = objectMapper.readValue(inputStream, NicknameData.class); + this.adjectives = data.adj(); + this.nouns = data.noun(); + }catch(IOException e){ + throw new RuntimeException("Failed to load nickname components", e); + } + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + + //๋‹‰๋„ค์ž„ ๋žœ๋ค ์ƒ์„ฑ ๋กœ์ง + public String generateRandomNickname(String excludeNickname) { + //maxTries ๋งŒํผ ์‹œ๋„ + int maxTries = 20; + for(int i = 0; i < maxTries; i++){ + //๋žœ๋ค ํ˜•์šฉ์‚ฌ+๋ช…์‚ฌ ์กฐํ•ฉ์œผ๋กœ ์ƒˆ ๋‹‰๋„ค์ž„ ์ƒ์„ฑ + String newAdj = adjectives.get(random.nextInt(adjectives.size())); + String newNoun = nouns.get(random.nextInt(nouns.size())); + String candidate = newAdj + " " + newNoun; + //์ค‘๋ณต ๊ฒ€์‚ฌ ํ†ต๊ณผํ•˜๋ฉด ์ƒˆ๋กœ์šด ๋‹‰๋„ค์ž„ ๋ฐ˜ํ™˜ + //1)๋‚ด ๊ธฐ์กด ๋‹‰๋„ค์ž„๊ณผ ๋‹ค๋ฆ„ 2) ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž ๋‹‰๋„ค์ž„๊ณผ ๋‹ค๋ฆ„ + if(!excludeNickname.equals(candidate) && !userRepository.existsByNickname(candidate)){ + return candidate; + } + } + //๊ฒ€์‚ฌ ํ†ต๊ณผ ๋ชปํ•˜๋ฉด ์˜ˆ์™ธ์ฒ˜๋ฆฌ + throw new BusinessException(ErrorCode.NICKNAME_GENERATION_FAILED); + + } + @Transactional + public NicknameResponseDTO updateRandomNickname(Long userId) { + User user = findUserById(userId); + String oldNickname = user.getNickname(); + String newNickname = generateRandomNickname(oldNickname); + user.setNickname(newNickname); + + + return NicknameResponseDTO.builder() + .nickname(newNickname).build(); + + } +} diff --git a/src/main/java/com/example/lionsforest/domain/user/service/UserService.java b/src/main/java/com/example/lionsforest/domain/user/service/UserService.java new file mode 100644 index 0000000..37783dd --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/service/UserService.java @@ -0,0 +1,98 @@ +package com.example.lionsforest.domain.user.service; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.dto.response.NicknameResponseDTO; +import com.example.lionsforest.domain.user.dto.response.UserInfoResponseDTO; +import com.example.lionsforest.domain.user.dto.request.UserUpdateRequestDTO; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.common.S3UploadService; +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // ๊ธฐ๋ณธ์ ์œผ๋กœ ์ฝ๊ธฐ ์ „์šฉ +public class UserService { + + private final UserRepository userRepository; + private final S3UploadService s3UploadService; + + //์œ ์ € ๋ชฉ๋ก ์ „์ฒด ์กฐํšŒ + public List getAllUsers() { + return userRepository.findAll().stream() + .map(UserInfoResponseDTO::from) // ๋ฉ”์„œ๋“œ ์ฐธ์กฐ + .collect(Collectors.toList()); + } + + //์œ ์ € ์ •๋ณด ์ƒ์„ธ ์กฐํšŒ + public UserInfoResponseDTO getUserInfo(Long userId) { + User user = findUserById(userId); + return UserInfoResponseDTO.from(user); + } + + // ์œ ์ € ์ •๋ณด ์ˆ˜์ • + @Transactional + public UserInfoResponseDTO updateUserInfo(Long userId, UserUpdateRequestDTO request) { + User user = findUserById(userId); + + // ๋‹‰๋„ค์ž„ ๊ฒ€์‚ฌ (๋ณ€๊ฒฝ ์‹œ์—๋งŒ) + if (request.getNickname() != null){ + // ๊ธธ์ด ๊ฒ€์‚ฌ - ์ตœ๋Œ€ 10์ž๋กœ ์ œํ•œ + if(request.getNickname().length() > 10){ //๊ธธ์ด ๊ฒ€์‚ฌ + throw new BusinessException(ErrorCode.NICKNAME_LENGTH_EXCEEDED); + } + //์ฆ๋ณต ๊ฒ€์‚ฌ - ๋‚ด ๋‹‰๋„ค์ž„๊ณผ ๋‹ค๋ฅด๋ฉด์„œ + ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋‹‰๋„ค์ž„์ธ์ง€ + else if(!request.getNickname().equals(user.getNickname()) && + userRepository.existsByNickname(request.getNickname())){ + throw new BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS); + } + } + + // ๋‹‰๋„ค์ž„, ํ•œ ์ค„ ์†Œ๊ฐœ ์—…๋ฐ์ดํŠธ + user.updateNicknameAndBio(request.getNickname(), request.getBio()); + + // photo S3์— ์—…๋กœ๋“œ - ์š”์ฒญ๋ฐ›์€ photo๊ฐ€ ์กด์žฌํ•  ๋•Œ๋งŒ + MultipartFile photo = request.getPhoto(); + boolean removePhotoFlag = (request.getRemovePhoto() != null && request.getRemovePhoto()); + + //์‚ฌ์ง„ ์ œ๊ฑฐํ•˜๋Š” ๊ฒฝ์šฐ + if(removePhotoFlag) { + //๊ธฐ์กด ์‚ฌ์ง„์ด ์žˆ์œผ๋ฉด S3์—์„œ ์‚ญ์ œ + if(user.getProfile_photo() != null) { + s3UploadService.delete(user.getProfile_photo()); + } + //DB์—๋„ null๋กœ ์„ค์ • + user.setProfile_photo(null); + } + //์ œ๊ฑฐ ์š”์ฒญ X, ์ƒˆ ์‚ฌ์ง„ ์—…๋กœ๋“œ + else if(photo != null) { + //๊ธฐ์กด ์‚ฌ์ง„ ์žˆ์œผ๋ฉด s3์—์„œ ์‚ญ์ œ + if(user.getProfile_photo() != null) { + s3UploadService.delete(user.getProfile_photo()); + } + String newPhotoUrl = s3UploadService.upload(photo, "profile_photo"); + //DB์— ์ƒˆ photoUrl ์ €์žฅ + user.setProfile_photo(newPhotoUrl); + } + + return UserInfoResponseDTO.from(user); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/global/common/BaseTimeEntity.java b/src/main/java/com/example/lionsforest/global/common/BaseTimeEntity.java new file mode 100644 index 0000000..f3676ac --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/common/BaseTimeEntity.java @@ -0,0 +1,24 @@ +package com.example.lionsforest.global.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseTimeEntity { + @CreatedDate + @Column(updatable = false, name = "created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/example/lionsforest/global/common/S3UploadService.java b/src/main/java/com/example/lionsforest/global/common/S3UploadService.java new file mode 100644 index 0000000..927150d --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/common/S3UploadService.java @@ -0,0 +1,106 @@ +package com.example.lionsforest.global.common; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; + +import java.io.IOException; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3UploadService { + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.region.static}") + private String region; + + /** + * S3์— ํŒŒ์ผ ์—…๋กœ๋“œ (AWS SDK v2) + * @param multipartFile ์—…๋กœ๋“œํ•  ํŒŒ์ผ + * @param dirName S3 ๋ฒ„ํ‚ท ๋‚ด ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ (์˜ˆ: "group-photos", "review-photos") + * @return ์—…๋กœ๋“œ๋œ ํŒŒ์ผ์˜ S3 URL + */ + public String upload(MultipartFile multipartFile, String dirName) { + String originalFilename = multipartFile.getOriginalFilename(); + String fileName = dirName + "/" + UUID.randomUUID() + "_" + originalFilename; + + try { + // PutObjectRequest ์ƒ์„ฑ + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(multipartFile.getContentType()) + .contentLength(multipartFile.getSize()) + .build(); + + // S3์— ์—…๋กœ๋“œ + s3Client.putObject(putObjectRequest, + RequestBody.fromInputStream(multipartFile.getInputStream(), multipartFile.getSize())); + + // ์—…๋กœ๋“œ๋œ ํŒŒ์ผ์˜ S3 URL ์ƒ์„ฑ ๋ฐ ๋ฐ˜ํ™˜ + String fileUrl = String.format("https://%s.s3.%s.amazonaws.com/%s", bucket, region, fileName); + log.info("S3 ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต: {}", fileUrl); + + return fileUrl; + + } catch (S3Exception e) { + log.error("S3 ์—…๋กœ๋“œ ์‹คํŒจ (S3Exception): {}", originalFilename, e); + throw new IllegalArgumentException("S3 ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: " + e.awsErrorDetails().errorMessage()); + } catch (IOException e) { + log.error("ํŒŒ์ผ ์ฝ๊ธฐ ์‹คํŒจ: {}", originalFilename, e); + throw new IllegalArgumentException("ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ IO ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + } + + /** + * S3์—์„œ ํŒŒ์ผ ์‚ญ์ œ + * @param fileUrl ์‚ญ์ œํ•  ํŒŒ์ผ์˜ S3 URL + */ + public void delete(String fileUrl) { + try { + String fileName = extractFileNameFromUrl(fileUrl); + + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .build(); + + s3Client.deleteObject(deleteObjectRequest); + log.info("S3 ํŒŒ์ผ ์‚ญ์ œ ์„ฑ๊ณต: {}", fileName); + + } catch (S3Exception e) { + log.error("S3 ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจ (S3Exception): {}", fileUrl, e); + throw new IllegalArgumentException("S3 ํŒŒ์ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: " + e.awsErrorDetails().errorMessage()); + } catch (Exception e) { + log.error("ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจ: {}", fileUrl, e); + throw new IllegalArgumentException("ํŒŒ์ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + } + + /** + * S3 URL์—์„œ ํŒŒ์ผ๋ช…(ํ‚ค) ์ถ”์ถœ + */ + private String extractFileNameFromUrl(String fileUrl) { + try { + // https://bucket-name.s3.region.amazonaws.com/path/to/file.jpg + String[] parts = fileUrl.split(".com/"); + if (parts.length < 2) { + throw new IllegalArgumentException("์ž˜๋ชป๋œ S3 URL ํ˜•์‹์ž…๋‹ˆ๋‹ค: " + fileUrl); + } + return parts[1]; + } catch (Exception e) { + log.error("URL ํŒŒ์‹ฑ ์‹คํŒจ: {}", fileUrl, e); + throw new IllegalArgumentException("์ž˜๋ชป๋œ S3 URL ํ˜•์‹์ž…๋‹ˆ๋‹ค: " + fileUrl); + } + } +} diff --git a/src/main/java/com/example/lionsforest/global/component/FirebaseTokenVerifier.java b/src/main/java/com/example/lionsforest/global/component/FirebaseTokenVerifier.java new file mode 100644 index 0000000..7a99044 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/component/FirebaseTokenVerifier.java @@ -0,0 +1,35 @@ +package com.example.lionsforest.global.component; + +import com.example.lionsforest.domain.user.dto.request.UserInfoRequestDTO; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import org.springframework.stereotype.Component; + +import javax.naming.AuthenticationException; + +@Component +public class FirebaseTokenVerifier { + public UserInfoRequestDTO verifyIdToken(String firebaseIdToken) throws AuthenticationException { + try { + //1. Firebase Admin SDK๋กœ ํ† ํฐ ๊ฒ€์ฆ + FirebaseToken decodedToken = FirebaseAuth.getInstance().verifyIdToken(firebaseIdToken); + + //2. Firebase ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ + String email = decodedToken.getEmail(); + String name = decodedToken.getName(); + String profilePhoto = decodedToken.getPicture(); + String uid = decodedToken.getUid(); //firebase ๊ณ ์œ  uid + + //3. AuthService๊ฐ€ ์‚ฌ์šฉํ•˜๋˜ UserInfoDTO๋กœ ๋ณ€ํ™˜ + return UserInfoRequestDTO.builder() + .name(name) + .email(email) + .profile_photo(profilePhoto) + .build(); + + }catch(FirebaseAuthException e){ + throw new AuthenticationException("Invalid Firebase token: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/example/lionsforest/global/component/GoogleOAuthService.java b/src/main/java/com/example/lionsforest/global/component/GoogleOAuthService.java new file mode 100644 index 0000000..ed71389 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/component/GoogleOAuthService.java @@ -0,0 +1,97 @@ +package com.example.lionsforest.global.component; + +import com.example.lionsforest.domain.user.dto.request.LoginRequestDTO; +import com.example.lionsforest.domain.user.dto.request.UserInfoRequestDTO; +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Collections; + +@Slf4j +@Component +public class GoogleOAuthService { + + private final String clientId; + private final String clientSecret; + //private final String redirectUri; + + public GoogleOAuthService( + @Value("${google.auth.client-id}") String clientId, + @Value("${google.auth.client-secret}") String clientSecret + //@Value("${google.auth.redirect-uri}") String redirectUri + ){ + this.clientId = clientId; + this.clientSecret = clientSecret; + //this.redirectUri = redirectUri; + } + + //code๋ฅผ google token์œผ๋กœ ๊ตํ™˜ํ•˜๊ณ  ์œ ์ € ์ •๋ณด ์ถ”์ถœ + public UserInfoRequestDTO getUserInfo(LoginRequestDTO request) { + try{ + + //url-encode๋œ ์ฝ”๋“œ๋ฅผ rqw code๋กœ ๋””์ฝ”๋”ฉ + //String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8); + + String code = request.getCode(); + String redirectUri = request.getRedirectUri(); + //code๋ฅผ token์œผ๋กœ ๊ตํ™˜ + GoogleTokenResponse tokenResponse = exchangeCodeForToken(code, redirectUri); + + //id token ์ถ”์ถœ ๋ฐ ํŒŒ์‹ฑ + String idTokenString = tokenResponse.getIdToken(); + GoogleIdToken idToken = GoogleIdToken.parse(GsonFactory.getDefaultInstance(), idTokenString); + + //ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ + if(!idToken.verifyAudience(Collections.singletonList(clientId))){ + throw new BusinessException(ErrorCode.INVALID_GOOGLE_ID_TOKEN); + } + GoogleIdToken.Payload payload = idToken.getPayload(); + + //ํŽ˜์ด๋กœ๋“œ์—์„œ ์ •๋ณด ์ถ”์ถœ + String email = payload.getEmail(); + String name = (String) payload.get("name"); + String profilePhoto = (String) payload.get("picture"); + + if(email == null || name == null){ + throw new BusinessException(ErrorCode.GOOGLE_USER_INFO_NOT_FOUND); + } + + return UserInfoRequestDTO.builder() + .name(name) + .email(email) + .profile_photo(profilePhoto) + .build(); + } catch(IOException e){ + log.error("Google ์ธ์ฆ ์ฝ”๋“œ ๊ตํ™˜ ๋˜๋Š” ํ† ํฐ ํŒŒ์‹ฑ ์‹คํŒจ: {}", e.getMessage(), e); + throw new BusinessException(ErrorCode.GOOGLE_SERVER_ERROR); + } catch(Exception e){ // ๊ทธ ์™ธ ์˜ค๋ฅ˜ + log.error("Google ๋กœ๊ทธ์ธ ์ค‘ ์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜: {}", e.getMessage(), e); + throw new BusinessException(ErrorCode.GOOGLE_LOGIN_FAILED); + } + } + + //๊ตฌ๊ธ€์— code ๋ณด๋‚ด์„œ token ๋ฐ›์•„์˜ค๋Š” ๋‚ด๋ถ€ ๋ฉ”์„œ๋“œ + private GoogleTokenResponse exchangeCodeForToken(String code, String redirectUri) throws IOException { + return new GoogleAuthorizationCodeTokenRequest( + new NetHttpTransport(), + GsonFactory.getDefaultInstance(), + "https://oauth2.googleapis.com/token", + clientId, + clientSecret, + code, + redirectUri + ).execute(); + } +} diff --git a/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java b/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java new file mode 100644 index 0000000..bfd3a0f --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java @@ -0,0 +1,61 @@ +package com.example.lionsforest.global.component; + +import com.example.lionsforest.domain.user.dto.request.UserInfoRequestDTO; +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Collections; + +@Component +public class GoogleTokenVerifier { + + private final GoogleIdTokenVerifier verifier; + private final String clientId; + + public GoogleTokenVerifier(@Value("${google.auth.client-id}") String clientId) { + this.clientId = clientId; + NetHttpTransport transport = new NetHttpTransport(); + GsonFactory jsonFactory = GsonFactory.getDefaultInstance(); + + this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory) + .setAudience(Collections.singletonList(clientId)) + .build(); + } + + public UserInfoRequestDTO verify(String idToken) { + try { + if (clientId == null || clientId.isBlank() || clientId.contains("YOUR_GOOGLE_CLIENT_ID")) { + throw new BusinessException(ErrorCode.GOOGLE_CLIENT_ID_CONFIG_ERROR); + } + + GoogleIdToken googleIdToken = verifier.verify(idToken); + if (googleIdToken == null) { + throw new BusinessException(ErrorCode.INVALID_GOOGLE_ID_TOKEN); + } + + GoogleIdToken.Payload payload = googleIdToken.getPayload(); + String email = payload.getEmail(); + String name = (String) payload.get("name"); + String profile_photo = (String) payload.get("picture"); + + if (email == null || name == null) { + throw new BusinessException(ErrorCode.GOOGLE_USER_INFO_NOT_FOUND); + } + + return UserInfoRequestDTO.builder() + .name(name) + .email(email) + .profile_photo(profile_photo) + .build(); + + } catch (Exception e) { + throw new SecurityException("๊ตฌ๊ธ€ ID ํ† ํฐ ๊ฒ€์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. " + e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java b/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java new file mode 100644 index 0000000..7614ec0 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java @@ -0,0 +1,69 @@ +package com.example.lionsforest.global.component; + +import com.example.lionsforest.global.exception.ErrorCode; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +@Slf4j //๋กœ๊น… ์œ„ํ•œ ์–ด๋…ธํ…Œ์ด์…˜ +@Component +public class MemberWhitelistValidator { + // (ใ…‡ใ…ฃ๋ฉ”์ผ, ์ด๋ฆ„) ์ €์žฅ ๋งต + private final Map whitelist = new HashMap<>(); + + @PostConstruct + public void loadWhitelist() { + //members.txt ๋กœ๋“œ + ClassPathResource resource = new ClassPathResource("members.txt"); + + try(BufferedReader reader = new BufferedReader( + new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { + + String line; + // txt ํŒŒ์ผ ๋๊นŒ์ง€ ํŒŒ์‹ฑ -> (์ด๋ฉ”์ผ, ์ด๋ฆ„) ์ €์žฅ + while ((line = reader.readLine()) != null) { + String[] parts = line.split(","); + if (parts.length == 2) { + String name = parts[0].trim(); + String email = parts[1].trim(); + whitelist.put(email, name); // (์ด๋ฉ”์ผ์„ Key, ์ด๋ฆ„์„ Value) + } + } + log.info("Loaded {} members into whitelist", whitelist.size()); + // ๋งต์— ์‹ค์ œ๋กœ ์ €์žฅ๋œ Key-Value ์Œ ์ „์ฒด๋ฅผ ์ถœ๋ ฅ + log.info("Whitelist contents: {}", whitelist); + + }catch(IOException e){ + log.error("Failed to load whitelist: {}", ErrorCode.WHITELIST_LOAD_FAILED); + throw new RuntimeException("Application startup failed due to Whitelist file loading error.", e); + } + } + + //๊ตฌ๊ธ€ ์ •๋ณด์™€ whitelist ์ผ์น˜ํ•˜๋Š”์ง€ ๊ฒ€์ฆ + public boolean isMember(String googleName, String googleEmail) { + //whitelist์— ์ด๋ฉ”์ผ ํ‚ค ์žˆ๋Š”์ง€ ๊ฒ€์ฆ + if(!whitelist.containsKey(googleEmail)){ + log.warn("User {} does not have a whitelisted member. (Email NOT FOUND)", googleEmail); + return false; + } + + //์ด๋ฆ„ ๋น„๊ต + String whitelistName = whitelist.get(googleEmail); + boolean isMatch = googleName.equals(whitelistName); + if(!isMatch){ + log.info("Name mismatch (but allowing login): Google='{}', Whitelist='{}'", googleName, whitelistName); + } + return true; + } + +} + + diff --git a/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java b/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java new file mode 100644 index 0000000..f6dc993 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java @@ -0,0 +1,40 @@ +package com.example.lionsforest.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; + +import java.io.IOException; +import java.io.InputStream; + +@Configuration +public class FirebaseConfig { + //firebase ๊ณ„์ •์— ๋Œ€ํ•œ json ํŒŒ์ผ ๊ฒฝ๋กœ + @Value("classpath:firebase-service-account.json") + private Resource serviceAccountKey; + + @PostConstruct + public void initializeFirebase(){ + try(InputStream is = serviceAccountKey.getInputStream()){ + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(is)) + .build(); + + //์ด๋ฏธ ์ดˆ๊ธฐํ™”๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + if(FirebaseApp.getApps().isEmpty()){ + FirebaseApp.initializeApp(options); + } + }catch(IOException e){ + // TODO: ์˜ˆ์™ธ์ฒ˜๋ฆฌ + e.printStackTrace(); + } + + } + +} + + diff --git a/src/main/java/com/example/lionsforest/global/config/PrincipalHandler.java b/src/main/java/com/example/lionsforest/global/config/PrincipalHandler.java new file mode 100644 index 0000000..6c62053 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/config/PrincipalHandler.java @@ -0,0 +1,34 @@ +package com.example.lionsforest.global.config; + + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +public class PrincipalHandler { + + // SecurityContext์—์„œ ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ID (Long)๋ฅผ ๊ฐ€์ ธ์˜ด + public static Long getUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new RuntimeException("์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. (SecurityContext is empty)"); + } + + Object principal = authentication.getPrincipal(); + + if (principal instanceof UserDetails) { + // JwtTokenProvider์—์„œ UserDetails์˜ username ํ•„๋“œ์— userId๋ฅผ ๋ฌธ์ž์—ด๋กœ ์ €์žฅํ–ˆ์Œ + String userIdString = ((UserDetails) principal).getUsername(); + try { + return Long.parseLong(userIdString); + } catch (NumberFormatException e) { + throw new RuntimeException("์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž ID ํ˜•์‹์ž…๋‹ˆ๋‹ค.", e); + } + } else if (principal instanceof String && "anonymousUser".equals(principal)) { + throw new RuntimeException("์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค. (anonymousUser)"); + } + + throw new RuntimeException("์œ ํšจํ•œ ์ธ์ฆ ์ฃผ์ฒด(Principal)๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/global/config/S3Config.java b/src/main/java/com/example/lionsforest/global/config/S3Config.java new file mode 100644 index 0000000..9e89ea3 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/config/S3Config.java @@ -0,0 +1,31 @@ +package com.example.lionsforest.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +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; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public S3Client s3Client() { + AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } +} diff --git a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java new file mode 100644 index 0000000..1a4905c --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -0,0 +1,92 @@ +package com.example.lionsforest.global.config; + + +import com.example.lionsforest.global.jwt.JwtAuthenticationFilter; +import com.example.lionsforest.global.jwt.JwtTokenProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwtTokenProvider, CorsConfigurationSource corsConfigurationSource) throws Exception { + http + // CORS ์„ค์ • ์ถ”๊ฐ€ + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + // CSRF ๋น„ํ™œ์„ฑํ™” + .csrf(AbstractHttpConfigurer::disable) + + // ์„ธ์…˜ ๊ด€๋ฆฌ ๋น„ํ™œ์„ฑํ™” + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // ํผ ๋กœ๊ทธ์ธ/HTTP Basic ์ธ์ฆ ๋น„ํ™œ์„ฑํ™” + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + + // OAuth2 ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ๋น„ํ™œ์„ฑํ™” + // ์ด๊ฑธ ๊บผ์•ผ /auth/google ์š”์ฒญ์ด ์ปจํŠธ๋กค๋Ÿฌ๋กœ ๊ฐ + .oauth2Login(AbstractHttpConfigurer::disable) + + // API ์—”๋“œํฌ์ธํŠธ๋ณ„ ์ ‘๊ทผ ๊ถŒํ•œ ์„ค์ • + .authorizeHttpRequests(auth -> auth + // ์ธ์ฆ ์—†์ด ๋ฌด์กฐ๊ฑด ํ†ต๊ณผ(permitAll)ํ•˜๋Š” ๊ฒฝ๋กœ + .requestMatchers("/auth/**","/swagger-ui/**", "/v3/api-docs/**", "/api/health" + ).permitAll() + //api ๋ฐ ๋‹ค๋ฅธ ๊ฒฝ๋กœ: ์ธ์ฆ ๋˜๋ฉด ํ†ต๊ณผ + .requestMatchers("/api/**").authenticated() + .anyRequest().authenticated() + ) + //jwt ํ•„ํ„ฐ ์ถ”๊ฐ€ + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class); + + + return http.build(); + } + + //CORS ์„ค์ • ์œ„ํ•œ Bean + //ํ”„๋ก ํŠธ์—”๋“œ ๋„๋ฉ”์ธ์—์„œ์˜ ์š”์ฒญ์„ ํ—ˆ์šฉํ•จ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + //ํ—ˆ์šฉํ•  origin: ํ”„๋ก ํŠธ์—”๋“œ ๋„๋ฉ”์ธ(https://lions-forest.p-e.kr) & ๋กœ์ปฌ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ + config.setAllowedOrigins(Arrays.asList( + //ํ”„๋ก ํŠธ์—”๋“œ ์ฃผ์†Œ + "https://lions-forest.p-e.kr", + "http://lions-forest.p-e.kr", + "http://localhost:3000", + "http://localhost:5173", + "https://lionforest-dev.netlify.app", + "https://lions-forest.vercel.app", + "https://lionforest.netlify.app", + //๋ฐฑ์—”๋“œ ์ฃผ์†Œ + "https://api.lions-forest.p-e.kr", + "http://localhost:8080" + )); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); //ํ—ˆ์šฉํ•  http ๋ฉ”์„œ๋“œ + config.setAllowedHeaders(Arrays.asList("*")); //๋ชจ๋“  http ํ—ค๋” ํ—ˆ์šฉ + config.setAllowCredentials(true); // ์ž๊ฒฉ ์ฆ๋ช…(์ฟ ํ‚ค, authorization ํ—ค๋”) ํ—ˆ์šฉ + config.setMaxAge(3600L); //์š”์ฒญ ์บ์‹œ ์‹œ๊ฐ„: 1์‹œ๊ฐ„ + + //์œ„ ์„ค์ •์„ ๋ชจ๋“  ๊ฒฝ๋กœ์— ์ ์šฉ + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return source; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java b/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java new file mode 100644 index 0000000..b03742a --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java @@ -0,0 +1,66 @@ +package com.example.lionsforest.global.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import io.swagger.v3.oas.models.servers.Server; + +import static java.awt.SystemColor.info; + +@OpenAPIDefinition +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + Info info = new Info() + .title("API ์ œ๋ชฉ") + .description("API ์„ค๋ช…") + .version("v1.0.0"); + + + // 2. [์ถ”๊ฐ€] SecurityScheme ์ด๋ฆ„ ์ •์˜ + String securitySchemeName = "bearerAuth"; + + // 3. [์ถ”๊ฐ€] SecurityRequirement ์ƒ์„ฑ (์ „์—ญ ์ž๋ฌผ์‡  ์„ค์ •) + SecurityRequirement securityRequirement = + new SecurityRequirement().addList(securitySchemeName); + + // 4. [์ถ”๊ฐ€] SecurityScheme ์ •์˜ (JWT Bearer ๋ฐฉ์‹) + Components components = new Components() + .addSecuritySchemes(securitySchemeName, new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) // HTTP ๋ฐฉ์‹ + .scheme("bearer") // bearer ํ† ํฐ ์‚ฌ์šฉ + .bearerFormat("JWT")); // JWT ํฌ๋งท + + // 5. OpenAPI ๊ฐ์ฒด ์ƒ์„ฑ ๋ฐ ์„ค์ • ์ ์šฉ + return new OpenAPI() + .info(info) // 1๋ฒˆ Info ์ ์šฉ + .addSecurityItem(securityRequirement) // 3๋ฒˆ SecurityRequirement ์ ์šฉ + .components(components); // 4๋ฒˆ Components ์ ์šฉ + } + + // 3. [์ถ”๊ฐ€] "development" ํ”„๋กœํ•„์ผ ๋•Œ "๋กœ์ปฌ ์„œ๋ฒ„" ์ •๋ณด๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + // (application.yml์˜ active: development์™€ ์ผ์น˜) + @Bean + @Profile("development") + public OpenAPI developmentServer() { + return new OpenAPI() + .addServersItem(new Server().url("http://localhost:8080").description("Local Development Server")); + } + + // 4. [์ถ”๊ฐ€] "deployment" ํ”„๋กœํ•„์ผ ๋•Œ "๋ฐฐํฌ ์„œ๋ฒ„" ์ •๋ณด๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + @Bean + @Profile("deployment") + public OpenAPI deploymentServer() { + return new OpenAPI() + .addServersItem(new Server().url("https://api.lions-forest.p-e.kr").description("Production Server")); + } +} diff --git a/src/main/java/com/example/lionsforest/global/exception/BusinessException.java b/src/main/java/com/example/lionsforest/global/exception/BusinessException.java new file mode 100644 index 0000000..26d2929 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/exception/BusinessException.java @@ -0,0 +1,21 @@ +package com.example.lionsforest.global.exception; + +import lombok.Getter; + +//GlobalExceptionHandler๊ฐ€ @ExceptionHandler(BusinessException.class) +//ํ•˜๋‚˜๋กœ ๋ชจ๋“  ๋น„์ฆˆ๋‹ˆ์Šค ์˜ˆ์™ธ๋ฅผ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก BusinessException ํด๋ž˜์Šค ์‚ฌ์šฉ +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); //๋ถ€๋ชจ ์ƒ์„ฑ์ž์— ๋ฉ”์‹œ์ง€ ์ „๋‹ฌ + this.errorCode = errorCode; + } + + //๊ทผ๋ณธ ์›์ธ์ด ๋˜๋Š” ์˜ˆ์™ธ๋ฅผ ํ•จ๊ป˜ ์ „๋‹ฌ๋ฐ›๋Š” ์ƒ์„ฑ์ž(ํ•„์š”์‹œ ์‚ฌ์šฉ) + public BusinessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java new file mode 100644 index 0000000..d0d00eb --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -0,0 +1,92 @@ +package com.example.lionsforest.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + //400 BAD_REQUEST + INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "INVALID_PARAMETER", "ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + + // 401 UNAUTHORIZED + INVALID_ID_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_ID_TOKEN", "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + + // 403 FORBIDDEN + USER_NOT_IN_WHITELIST(HttpStatus.FORBIDDEN, "USER_NOT_IN_WHITELIST", "๋™์•„๋ฆฌ ๋ถ€์› ๋ช…๋‹จ์— ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + + // 404 NOT_FOUND + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €์ž…๋‹ˆ๋‹ค."), + + // 409 CONFLICT + NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "NICKNAME_ALREADY_EXISTS", "์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ๋‹‰๋„ค์ž„์ž…๋‹ˆ๋‹ค."), + + // ๋‹‰๋„ค์ž„ ์ƒ์„ฑ ์‹คํŒจ + NICKNAME_GENERATION_FAILED(HttpStatus.NO_CONTENT, "NICKNAME_GENERATION_FAILED", "์ƒˆ๋กœ์šด ๋‹‰๋„ค์ž„ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + + //๋Œ“๊ธ€ ์กฐํšŒ ์‹คํŒจ + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_NOT_FOUND", "๋Œ“๊ธ€ ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค"), + + //๋ชจ์ž„ ์—†์Œ + GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "GROUP_NOT_FOUND", "๋ชจ์ž„ ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค"), + + // ๊ถŒํ•œ ์—†์Œ + GROUP_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "GROUP_PERMISSION_DENIED", "ํ•ด๋‹น ๋ชจ์ž„์— ๋Œ€ํ•œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + + // ๋ชจ์ž„ ์ทจ์†Œ ์‹œ์  ์ œํ•œ + GROUP_CANCEL_TIME_EXCEEDED(HttpStatus.BAD_REQUEST, "GROUP_CANCEL_TIME_EXCEEDED", "๋ชจ์ž„ ์‹œ์ž‘ ์ดํ›„์—๋Š” ์ทจ์†Œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // ๋ชจ์ž„ ์ƒ์„ฑ ์‹œ์  ์ œํ•œ + GROUP_CREATION_TIME_EXCEEDED(HttpStatus.BAD_REQUEST, "GROUP_CREATION_TIME_EXCEEDED", "๋ชจ์ž„ ๊ฐœ์„ค์€ ํ˜„์žฌ ์‹œ๊ฐ ์ดํ›„๋กœ๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), + + // ๋ชจ์ž„ ์ค‘๋ณต ์‹ ์ฒญ ์ œํ•œ + PARTICIPATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "PARTICIPATION_ALREADY_EXISTS", "์ด๋ฏธ ์ฐธ์—ฌ ์ค‘์ธ ๋ชจ์ž„์ž…๋‹ˆ๋‹ค."), + + // ๋ชจ์ž„ ์ธ์› ์ œํ•œ + GROUP_CAPACITY_FULL(HttpStatus.BAD_REQUEST, "GROUP_CAPACITY_FULL", "๋ชจ์ž„ ์ธ์›์ด ๊ฐ€๋“ ์ฐผ์Šต๋‹ˆ๋‹ค."), + + // ๋ชจ์ž„์žฅ์€ ํƒˆํ‡ดํ•  ์ˆ˜ ์—†์Œ + GROUP_LEADER_CANNOT_LEAVE(HttpStatus.FORBIDDEN, "GROUP_LEADER_CANNOT_LEAVE", "๋ชจ์ž„์žฅ์€ ๋ชจ์ž„์„ ํƒˆํ‡ดํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // ์ฐธ์—ฌํ•˜์ง€ ์•Š์€ ๋ชจ์ž„ + GROUP_PARTICIPATION_NOT_FOUND(HttpStatus.BAD_REQUEST, "GROUP_PARTICIPATION_NOT_FOUND", "์ฐธ์—ฌํ•˜์ง€ ์•Š์€ ๋ชจ์ž„์ž…๋‹ˆ๋‹ค."), + + // ๊ถŒํ•œ ์—†์Œ - ๋Œ“๊ธ€ + COMMENT_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "COMMENT_PERMISSION_DENIED", "ํ•ด๋‹น ๋Œ“๊ธ€์— ๋Œ€ํ•œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + + // ๊ถŒํ•œ ์—†์Œ - ํ›„๊ธฐ + REVIEW_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "REVIEW_PERMISSION_DENIED", "ํ•ด๋‹น ํ›„๊ธฐ์— ๋Œ€ํ•œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + + // ํ›„๊ธฐ ์—†์Œ + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW_NOT_FOUND", "ํ›„๊ธฐ ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค"), + + // ๋‹‰๋„ค์ž„ ์ƒ์„ฑ ์‹คํŒจ + NICKNAME_LENGTH_EXCEEDED(HttpStatus.BAD_REQUEST, "NICKNANE_LENGTH_EXCEEDED", "๋‹‰๋„ค์ž„์€ 10๊ธ€์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // ํŒŒ์ด์–ด๋ฒ ์ด์Šค ํ† ํฐ ์—๋Ÿฌ - 500 + FIREBASE_TOKEN_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FIREBASE_ERROR", "Firebase ํ† ํฐ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + + // ๊ตฌ๊ธ€ ์ธ์ฆ ์‹คํŒจ(ํฌ๊ด„) - 401 + GOOGLE_LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "GOOGLE_LOGIN_FAILED", "๊ตฌ๊ธ€ ๋กœ๊ทธ์ธ ์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + + // ๊ตฌ๊ธ€ idToken ์ •๋ณด ์œ ํšจํ•˜์ง€ ์•Š์Œ - 401 + INVALID_GOOGLE_ID_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_GOOGLE_ID_TOKEN", "๊ตฌ๊ธ€ id ํ† ํฐ์˜ client id๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + + // ๊ตฌ๊ธ€ idToken์—์„œ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Œ - 500 + GOOGLE_USER_INFO_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "GOOGLE_USER_INFO_NOT_FOUND", "Google ๊ณ„์ •์—์„œ ์ด๋ฉ”์ผ ๋˜๋Š” ์ด๋ฆ„ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // ๊ตฌ๊ธ€ ์„œ๋ฒ„ ํ†ต์‹  ์‹คํŒจ - 500 + GOOGLE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GOOGLE_SERVER_ERROR", "๊ตฌ๊ธ€ ์„œ๋ฒ„์™€์˜ ํ†ต์‹  ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + + // ๊ตฌ๊ธ€ client id ์„ค์ • ์—†์Œ - 500 + GOOGLE_CLIENT_ID_CONFIG_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GOOGLE_CLIENT_ID_CONFIG_ERROR", "๊ตฌ๊ธ€ client id๊ฐ€ application.yml์— ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."), + + // ๋™์•„๋ฆฌ ๋ช…๋‹จ ๋กœ๋”ฉ ์‹คํŒจ - 500 + WHITELIST_LOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "WHITELIST_LOAD_FAILED", "๋™์•„๋ฆฌ ๋ช…๋‹จ ํŒŒ์ผ ๋กœ๋”ฉ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); + + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/lionsforest/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/lionsforest/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..2dc764c --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,37 @@ +package com.example.lionsforest.global.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice //๋ชจ๋“  @RestController์—์„œ ๋ฐœ์ƒํ•˜๋Š” ์˜ˆ์™ธ๋ฅผ ์ฒ˜๋ฆฌํ•จ +public class GlobalExceptionHandler { + //์šฐ๋ฆฌ๊ฐ€ ์ •์˜ํ•œ BusinessException์„ ์ฒ˜๋ฆฌ + @ExceptionHandler(BusinessException.class) + protected ResponseEntity handleBusinessException(BusinessException e) { + log.warn("handleBusinessException: {}", e.getMessage()); + + ErrorCode errorCode = e.getErrorCode(); + ErrorResponse response = new ErrorResponse(errorCode.getCode(), errorCode.getMessage()); + + //ErrorCode์—์„œ ์ •์˜ํ•œ HttpStatus, ErrorResponse DTO๋ฅผ ๋ฐ˜ํ™˜ + return new ResponseEntity<>(response, errorCode.getStatus()); + } + + // ๋‚˜๋จธ์ง€ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ˆ์™ธ๋“ค(500 ์—๋Ÿฌ)์„ ์ฒ˜๋ฆฌ + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException(Exception e) { + log.error("handleException : {}", e.getMessage()); + + // ์ผ๋‹จ 500 ์—๋Ÿฌ๋กœ ์ฒ˜๋ฆฌ + ErrorResponse response = new ErrorResponse("INTERNAL_SERVER_ERROR", "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } + + // ErrorResponse DTO (๋‚ด๋ถ€ ํด๋ž˜์Šค ๋˜๋Š” ๋ณ„๋„ ํŒŒ์ผ๋กœ ์ •์˜) + public record ErrorResponse(String code, String message) { + } +} diff --git a/src/main/java/com/example/lionsforest/global/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/lionsforest/global/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..171108d --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,51 @@ +package com.example.lionsforest.global.jwt; + + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // 1. Request Header์—์„œ ํ† ํฐ ์ถ”์ถœ + String token = resolveToken(request); + + // 2. validateToken์œผ๋กœ ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + if (token != null && jwtTokenProvider.validateToken(token)) { + // 3. ํ† ํฐ์ด ์œ ํšจํ•  ๊ฒฝ์šฐ, ํ† ํฐ์—์„œ Authentication ๊ฐ์ฒด๋ฅผ ๊ฐ€์ ธ์™€ SecurityContext์— ์ €์žฅ + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + // (๋กœ๊ทธ) ์œ ์ € ID๊ฐ€ SecurityContext์— ์ €์žฅ๋จ + log.info("Security Context์— '{}' ์ธ์ฆ ์ •๋ณด๋ฅผ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค, uri: {}", authentication.getName(), request.getRequestURI()); + } + + // 4. ๋‹ค์Œ ํ•„ํ„ฐ๋กœ ์š”์ฒญ ์ „๋‹ฌ + filterChain.doFilter(request, response); + } + + // Request Header์—์„œ ํ† ํฐ ์ •๋ณด๋ฅผ ์ถ”์ถœํ•˜๋Š” ๋ฉ”์„œ๋“œ + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); // "Bearer " ์ดํ›„์˜ ํ† ํฐ ๊ฐ’ ๋ฐ˜ํ™˜ + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java b/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..e7fa841 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java @@ -0,0 +1,130 @@ +package com.example.lionsforest.global.jwt; + +import com.example.lionsforest.domain.user.dto.response.TokenResponseDTO; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +@Slf4j //๋กœ๊น… ์œ„ํ•œ ์–ด๋…ธํ…Œ์ด์…˜ +@Component +public class JwtTokenProvider { + private final Key key; + private final long accessTokenValidityInMs; + private final long refreshTokenValidityInMs; + + // application.yml ์—์„œ ๊ฐ’์„ ์ฃผ์ž…๋ฐ›์Œ + public JwtTokenProvider( + @Value("${jwt.secret-key}") String secretKey, + @Value("${jwt.access-token-validity-in-ms}") long accessTokenValidity, + @Value("${jwt.refresh-token-validity-in-ms}") long refreshTokenValidity) { + + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + this.accessTokenValidityInMs = accessTokenValidity; + this.refreshTokenValidityInMs = refreshTokenValidity; + } + + //์•ก์„ธ์Šค ํ† ํฐ & ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ์ƒ์„ฑ + public TokenResponseDTO createTokens(Long userId, String email) { + + String accessToken = generateToken(userId, email, accessTokenValidityInMs, "Access"); + String refreshToken = generateToken(userId, email, refreshTokenValidityInMs, "Refresh"); // Refresh Token์—๋„ ๊ธฐ๋ณธ ์ •๋ณด ํฌํ•จ + + return TokenResponseDTO.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // ์‹ค์ œ ํ† ํฐ์„ ์ƒ์„ฑํ•˜๋Š” ๋ฉ”์„œ๋“œ + private String generateToken(Long userId, String email, long validityMs, String tokenType) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityMs); + + JwtBuilder builder = Jwts.builder() + .setSubject(String.valueOf(userId)) //ํ† ํฐ ์ฃผ์ฒด(์œ ์ € ์•„์ด๋””) + .setIssuedAt(now) //๋ฐœ๊ธ‰ ์‹œ๊ฐ„ + .setExpiration(validity); //๋งŒ๋ฃŒ ์‹œ๊ฐ„ + if("Access".equals(tokenType)) { //์•ก์„ธ์Šค ํ† ํฐ์—๋งŒ email, auth ํด๋ ˆ์ž„ ์ถ”๊ฐ€ + builder.claim("email", email) //์ปค์Šคํ…€ ํด๋ ˆ์ž„(์ด๋ฉ”์ผ) + .claim("auth", "ROLE_USER"); //๊ถŒํ•œ ์ •๋ณด ํด๋ ˆ์ž„ ์ถ”๊ฐ€ + } + + return builder.signWith(key, SignatureAlgorithm.HS256).compact(); + } + + // ํ† ํฐ์—์„œ ์œ ์ € ์•„์ด๋”” ์ถ”์ถœ + public Long getUserIdFromToken(String token) { + Claims claims = parseClaims(token); + return Long.parseLong(claims.getSubject()); + } + + //ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.warn("์ž˜๋ชป๋œ JWT ์„œ๋ช…์ž…๋‹ˆ๋‹ค.", e); + } catch (ExpiredJwtException e) { + log.warn("๋งŒ๋ฃŒ๋œ JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค.", e); + } catch (UnsupportedJwtException e) { + log.warn("์ง€์›๋˜์ง€ ์•Š๋Š” JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค.", e); + } catch (IllegalArgumentException e) { + log.warn("JWT ํ† ํฐ์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", e); + } + return false; + } + + //ํ† ํฐ์—์„œ ์ •๋ณด ํŒŒ์‹ฑ - ์ถ”์ถœ + private Claims parseClaims(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + // ๋งŒ๋ฃŒ๋œ ํ† ํฐ์ด๋ผ๋„ Claims๋Š” ์ •์ƒ์ ์œผ๋กœ ํŒŒ์‹ฑ๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๋ฐ˜ํ™˜ + return e.getClaims(); + } + } + + //ํ† ํฐ์„ ๋ฐ›์•„ Authentication ๊ฐ์ฒด ๋ฐ˜ํ™˜ + public Authentication getAuthentication(String accessToken) { + // ํ† ํฐ ๋ณตํ˜ธํ™” + Claims claims = parseClaims(accessToken); + + if (claims.get("auth") == null) { + throw new RuntimeException("๊ถŒํ•œ ์ •๋ณด๊ฐ€ ์—†๋Š” ํ† ํฐ์ž…๋‹ˆ๋‹ค."); + } + + // ํด๋ ˆ์ž„์—์„œ ๊ถŒํ•œ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + Collection authorities = + Arrays.stream(claims.get("auth").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + // UserDetails ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค์–ด์„œ Authentication ๋ฐ˜ํ™˜ + // User(Spring Security) ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑ, sub ํด๋ ˆ์ž„(userId)์„ principal๋กœ ์‚ฌ์šฉ + UserDetails principal = new User(claims.getSubject(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } +} diff --git a/src/main/resources/application-deployment.yml b/src/main/resources/application-deployment.yml new file mode 100644 index 0000000..b2283a3 --- /dev/null +++ b/src/main/resources/application-deployment.yml @@ -0,0 +1,25 @@ +spring: + datasource: + url: jdbc:mysql://${database.deployment.host}/${database.name} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${database.username} + password: ${database.password} + +cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + s3: + bucket: lions-forest + region: + static: ap-northeast-2 + stack: + auto: false + +# Swagger +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui/index.html \ No newline at end of file diff --git a/src/main/resources/application-development.yml b/src/main/resources/application-development.yml new file mode 100644 index 0000000..e00c8c7 --- /dev/null +++ b/src/main/resources/application-development.yml @@ -0,0 +1,25 @@ +spring: + datasource: + url: jdbc:mysql://${database.development.host}/${database.name} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${database.username} + password: ${database.password} + +cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + s3: + bucket: lions-forest + region: + static: ap-northeast-2 + stack: + auto: false + +# Swagger +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui/index.html \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..f5b6862 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=lionsforest \ No newline at end of file diff --git a/src/main/resources/nickname-components.json b/src/main/resources/nickname-components.json new file mode 100644 index 0000000..3f6d6bb --- /dev/null +++ b/src/main/resources/nickname-components.json @@ -0,0 +1,62 @@ +{ + "adj": [ + "ํ–‰๋ณตํ•œ", + "์ฆ๊ฑฐ์šด", + "๋น›๋‚˜๋Š”", + "์šฉ๊ฐํ•œ", + "์นœ์ ˆํ•œ", + "๊ท€์—ฌ์šด", + "๋˜‘๋˜‘ํ•œ", + "๋ช…๋ž‘ํ•œ", + "์ƒ์พŒํ•œ", + "์—‰๋šฑํ•œ", + "์žฌ๋น ๋ฅธ", + "์‹ ๋น„ํ•œ", + "ํ‰ํ™”๋กœ์šด", + "์”ฉ์”ฉํ•œ", + "๋”ฐ๋œปํ•œ", + "์กธ๋ฆฐ", + "๋ฐฐ๊ณ ํ”ˆ", + "ํ˜ธ๊ธฐ์‹ฌ ๋งŽ์€", + "์ˆ˜์ค์€", + "์žฅ๋‚œ๊พธ๋Ÿฌ๊ธฐ", + "๋‚ ์Œ˜", + "๋Š ๋ฆ„ํ•œ", + "์ž์œ ๋กœ์šด", + "์šฐ์•„ํ•œ", + "ํŠผํŠผํ•œ", + "๋А๊ธ‹ํ•œ", + "๋ถ€์ง€๋Ÿฐํ•œ" + ], + "noun": [ + "๊ณ ์–‘์ด", + "๊ฐ•์•„์ง€", + "ํ˜ธ๋ž‘์ด", + "์‚ฌ์ž", + "์ฝ”๋ผ๋ฆฌ", + "๊ธฐ๋ฆฐ", + "ํŒ๋‹ค", + "์ฝ”์•Œ๋ผ", + "๋‹ค๋žŒ์ฅ", + "ํ† ๋ผ", + "๊ฑฐ๋ถ์ด", + "ํŽญ๊ท„", + "๋Œ๊ณ ๋ž˜", + "๊ณ ๋ž˜", + "์—ฌ์šฐ", + "๋Š‘๋Œ€", + "๊ณฐ", + "์‚ฌ์Šด", + "์›์ˆญ์ด", + "์บฅ๊ฑฐ๋ฃจ", + "ํ•˜๋งˆ", + "์น˜ํƒ€", + "์–ผ๋ฃฉ๋ง", + "์ˆ˜๋‹ฌ", + "ํ–„์Šคํ„ฐ", + "์˜ค๋ฆฌ", + "๋ถ€์—‰์ด", + "๋…์ˆ˜๋ฆฌ", + "์•ŒํŒŒ์นด" + ] +} \ No newline at end of file diff --git a/src/test/java/com/example/lionsforest/LionsforestApplicationTests.java b/src/test/java/com/example/lionsforest/LionsforestApplicationTests.java new file mode 100644 index 0000000..1f752d8 --- /dev/null +++ b/src/test/java/com/example/lionsforest/LionsforestApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.lionsforest; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class LionsforestApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/example/lionsforest/TokenGenerationTest.java b/src/test/java/com/example/lionsforest/TokenGenerationTest.java new file mode 100644 index 0000000..a89e882 --- /dev/null +++ b/src/test/java/com/example/lionsforest/TokenGenerationTest.java @@ -0,0 +1,46 @@ +package com.example.lionsforest; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.jwt.JwtTokenProvider; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Commit; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +class TokenGenerationTest { // ํด๋ž˜์Šค ์ด๋ฆ„์€ ์•„๋ฌด๊ฑฐ๋‚˜ ์ƒ๊ด€์—†์Šต๋‹ˆ๋‹ค. + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private UserRepository userRepository; + + @Test + @Transactional + @Commit + void generateTestToken() { + //DB์—์„œ ์ด๋ฉ”์ผ๋กœ ์œ ์ € ์ฐพ๊ฑฐ๋‚˜ ์ƒ์„ฑ + String testUserEmail = "test@example.com"; + User user = userRepository.findByEmail(testUserEmail) + .orElseGet(() -> { + User testUser = User.builder() + .email(testUserEmail) + .name("ํ…Œ์ŠคํŠธ์œ ์ €") + .build(); + return userRepository.save(testUser); + }); + Long testUserId = user.getId(); + + // ํ† ํฐ ์ƒ์„ฑ + var tokenResponseDTO = jwtTokenProvider.createTokens(testUserId, testUserEmail); + String accessToken = tokenResponseDTO.getAccessToken(); + + // ํ† ํฐ์„ ์ฝ˜์†”์— ์ถœ๋ ฅ + System.out.println("--- Generated Access Token ---"); + System.out.println(accessToken); + System.out.println("---------------------------------"); + } +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..4e86d28 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,37 @@ +# ํ…Œ์ŠคํŠธ ์‹œ์—๋งŒ ์ ์šฉ๋  ์„ค์ • + +spring: + # (1) main์˜ 'import' ๊ตฌ๋ฌธ์„ ๋ฎ์–ด์”๋‹ˆ๋‹ค. + # ์ด๊ฒƒ์œผ๋กœ ์กด์žฌํ•˜์ง€ ์•Š๋Š” application-secret.yml์„ ์ฐพ์ง€ ์•Š๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. + config: + import: "" # ๋นˆ ๊ฐ’์œผ๋กœ ๋ฎ์–ด์“ฐ๊ธฐ + + # (2) ํ”„๋กœํ•„์„ 'test'๋กœ ๋ฎ์–ด์“ฐ๊ธฐ + profiles: + active: test + + # (3) DB ์„ค์ •์„ H2๋กœ ๋ฎ์–ด์“ฐ๊ธฐ + datasource: + url: jdbc:h2:mem:testdb + driverClassName: org.h2.Driver + username: sa + password: + + # (4) JPA ์„ค์ •์„ H2์šฉ์œผ๋กœ ๋ฎ์–ด์“ฐ๊ธฐ + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + +# (5) ํ…Œ์ŠคํŠธ ์‹œ Placeholder ์˜ค๋ฅ˜ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ '๊ฐ€์งœ' ๊ฐ’ ์ œ๊ณต +# (main์˜ yml๊ณผ secret.yml์—์„œ ๊ฐ€์ ธ์˜ฌ ๊ฐ’๋“ค์„ ์—ฌ๊ธฐ์„œ ๊ฐ€์งœ๋กœ ์ œ๊ณต) +google: + auth: + client-id: "test-google-client-id-for-ci" + +jwt: + secret-key: "dGVzdC1zZWNyZXQtLWtleS1mb3Itam9vbmltLWJhY2tlbmQtcHJvamVjdC1hYmNkZWZn" + access-token-validity-in-ms: 1800000 + refresh-token-validity-in-ms: 604800000 \ No newline at end of file diff --git a/src/test/resources/firebase-service-account.json b/src/test/resources/firebase-service-account.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/src/test/resources/firebase-service-account.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/test/resources/members.txt b/src/test/resources/members.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/test/resources/nickname-components.json b/src/test/resources/nickname-components.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/src/test/resources/nickname-components.json @@ -0,0 +1 @@ +{} \ No newline at end of file