From fd07b053b242616f692e6918252ae8c36b9a8a26 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Thu, 6 Nov 2025 19:01:40 +0900 Subject: [PATCH 01/78] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=A7=81=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=84=B8=ED=8C=85=20=EB=B0=8F=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 3 + .gitignore | 40 +++ build.gradle | 39 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle | 1 + .../lionsforest/LionsforestApplication.java | 13 + .../lionsforest/domain/comment/Comment.java | 42 +++ .../lionsforest/domain/group/Group.java | 72 +++++ .../domain/group/GroupCategory.java | 10 + .../lionsforest/domain/group/GroupPhoto.java | 28 ++ .../lionsforest/domain/group/GroupState.java | 6 + .../domain/group/Participation.java | 31 +++ .../domain/notification/Notification.java | 36 +++ .../lionsforest/domain/radar/Radar.java | 43 +++ .../lionsforest/domain/radar/RadarState.java | 9 + .../lionsforest/domain/review/Review.java | 38 +++ .../domain/review/ReviewPhoto.java | 28 ++ .../example/lionsforest/domain/user/User.java | 93 +++++++ .../global/common/BaseTimeEntity.java | 24 ++ src/main/resources/application.properties | 1 + .../LionsforestApplicationTests.java | 13 + 24 files changed, 922 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/example/lionsforest/LionsforestApplication.java create mode 100644 src/main/java/com/example/lionsforest/domain/comment/Comment.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/Group.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/GroupCategory.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/GroupPhoto.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/GroupState.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/Participation.java create mode 100644 src/main/java/com/example/lionsforest/domain/notification/Notification.java create mode 100644 src/main/java/com/example/lionsforest/domain/radar/Radar.java create mode 100644 src/main/java/com/example/lionsforest/domain/radar/RadarState.java create mode 100644 src/main/java/com/example/lionsforest/domain/review/Review.java create mode 100644 src/main/java/com/example/lionsforest/domain/review/ReviewPhoto.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/User.java create mode 100644 src/main/java/com/example/lionsforest/global/common/BaseTimeEntity.java create mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/com/example/lionsforest/LionsforestApplicationTests.java 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/.gitignore b/.gitignore new file mode 100644 index 0000000..ef83185 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +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 \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..9cab71f --- /dev/null +++ b/build.gradle @@ -0,0 +1,39 @@ +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' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + 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..5be0b1e --- /dev/null +++ b/src/main/java/com/example/lionsforest/LionsforestApplication.java @@ -0,0 +1,13 @@ +package com.example.lionsforest; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +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..f8dfd1d --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/Comment.java @@ -0,0 +1,42 @@ +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 comment_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 String content; + + private Integer likes; + + //이 댓글을 좋아요한 유저 + @Builder.Default + @ManyToMany(mappedBy = "liked_comments") + private Set liked_by_users = new HashSet<>(); +} 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..c87ce7c --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/Group.java @@ -0,0 +1,72 @@ +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@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 count; + + @Column(nullable = false) + private LocalDateTime meeting_at; + + 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..3864638 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java @@ -0,0 +1,10 @@ +package com.example.lionsforest.domain.group; + +public enum GroupCategory { + MEAL, //식사 + WORK, //모각작 + CAFE, //카페 + 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..21fab61 --- /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 photo_order; +} 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..bceed97 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/Participation.java @@ -0,0 +1,31 @@ +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; + + @Column(nullable = false) + private Boolean is_active; +} 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..fa532d7 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/Notification.java @@ -0,0 +1,36 @@ +package com.example.lionsforest.domain.notification; + +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 Notification { + @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; + + @Column(nullable = false) + private boolean is_read; + + @Column(nullable = false, updatable = false) + private LocalDateTime created_at; +} 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..c955f02 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/Review.java @@ -0,0 +1,38 @@ +package com.example.lionsforest.domain.review; + +import com.example.lionsforest.domain.group.Group; +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.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Review extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long review_id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="group_id", nullable = false) + private Group group; + + @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..a520a55 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/ReviewPhoto.java @@ -0,0 +1,28 @@ +package com.example.lionsforest.domain.review; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@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/user/User.java b/src/main/java/com/example/lionsforest/domain/user/User.java new file mode 100644 index 0000000..fe99ef9 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/User.java @@ -0,0 +1,93 @@ +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Getter +@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; + + 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<>(); + +} 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..8fa00ee --- /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 created_at; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updated_at; +} 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/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() { + } + +} From 4875b2eaccc176c8da78d5319504d3b56d3136ad Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Sat, 8 Nov 2025 21:00:17 +0900 Subject: [PATCH 02/78] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20API=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/domain/group/Group.java | 9 ++ .../group/controller/GroupController.java | 52 ++++++++++ .../controller/ParticipationController.java | 41 ++++++++ .../dto/request/GroupDeleteRequestDto.java | 8 ++ .../group/dto/request/GroupRequestDto.java | 33 +++++++ .../dto/request/GroupUpdateRequestDto.java | 18 ++++ .../dto/request/ParticipationRequestDto.java | 8 ++ .../group/dto/response/GroupResponseDto.java | 35 +++++++ .../response/ParticipationResponseDto.java | 33 +++++++ .../group/repository/GroupRepository.java | 7 ++ .../repository/ParticipationRepository.java | 21 ++++ .../domain/group/service/GroupService.java | 99 +++++++++++++++++++ .../group/service/ParticipationService.java | 71 +++++++++++++ 13 files changed, 435 insertions(+) create mode 100644 src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/request/GroupDeleteRequestDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/request/GroupRequestDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/request/GroupUpdateRequestDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/request/ParticipationRequestDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/response/GroupResponseDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/response/ParticipationResponseDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/service/GroupService.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java diff --git a/src/main/java/com/example/lionsforest/domain/group/Group.java b/src/main/java/com/example/lionsforest/domain/group/Group.java index c87ce7c..0b66cb8 100644 --- a/src/main/java/com/example/lionsforest/domain/group/Group.java +++ b/src/main/java/com/example/lionsforest/domain/group/Group.java @@ -69,4 +69,13 @@ public class Group extends BaseTimeEntity { @OneToMany(mappedBy = "group", cascade = CascadeType.ALL) private List reviews = new ArrayList<>(); + public void update(String title, GroupCategory category, Integer count, LocalDateTime meeting_at, String location, GroupState state) { + this.title = title; + this.category = category; + this.count = count; + this.meeting_at = meeting_at; + this.location = location; + this.state = state; + } + } 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..b72d446 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -0,0 +1,52 @@ +package com.example.lionsforest.domain.group.controller; + +import com.example.lionsforest.domain.group.dto.request.GroupDeleteRequestDto; +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.group.service.GroupService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/groups") +public class GroupController { + + private final GroupService groupService; + + // 모임 개설 + @PostMapping + public ResponseEntity createGroup(@RequestBody GroupRequestDto dto){ + return ResponseEntity.ok(groupService.createGroup(dto)); + } + + // 모임 정보 전체 조회 + @GetMapping + public ResponseEntity> getAllGroups(){ + return ResponseEntity.ok(groupService.getAllGroup()); + } + + // 모임 정보 상세 조회 + @GetMapping("/{id}") + public ResponseEntity getGroupByID(@PathVariable Long id){ + return ResponseEntity.ok(groupService.getGroupById(id)); + } + + // 모임 정보 수정 + @PatchMapping("/{id}") + public ResponseEntity updateGroup(@PathVariable Long id, + @RequestBody GroupUpdateRequestDto dto){ + return ResponseEntity.ok(groupService.updateGroup(id, dto)); + } + + // 모임 삭제 + @DeleteMapping("/{id}") + public ResponseEntity deleteGroup(@PathVariable Long id, + @RequestBody GroupDeleteRequestDto dto){ + return ResponseEntity.ok("모임이 성공적으로 삭제되었습니다."); + } + +} 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..3f7506c --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java @@ -0,0 +1,41 @@ +package com.example.lionsforest.domain.group.controller; + +import com.example.lionsforest.domain.group.dto.request.ParticipationRequestDto; +import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; +import com.example.lionsforest.domain.group.repository.ParticipationRepository; +import com.example.lionsforest.domain.group.service.ParticipationService; + +import com.example.lionsforest.domain.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/participation") +public class ParticipationController { + private final ParticipationService participationService; + + // 모임 참여 + @PostMapping("/{group_id}") + public ResponseEntity joinGroup(@PathVariable Long groupId, + @RequestBody ParticipationRequestDto dto){ + return ResponseEntity.ok(participationService.joinGroup(groupId, dto.getUserId())); + } + + // 모임 탈퇴 + @DeleteMapping("/{group_id}") + public ResponseEntity leaveGroup(@PathVariable Long groupId, + @RequestBody ParticipationRequestDto dto) { + participationService.leaveGroup(groupId, dto.getUserId()); + return ResponseEntity.ok("모임에서 탈퇴했습니다."); + } + + // 모임 참여자 조회 + @GetMapping("/{group_id}") + public ResponseEntity> getUser(@PathVariable Long groupId){ + return ResponseEntity.ok(participationService.getParticipationsByGroupId(groupId)); + } +} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupDeleteRequestDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupDeleteRequestDto.java new file mode 100644 index 0000000..4749cfb --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupDeleteRequestDto.java @@ -0,0 +1,8 @@ +package com.example.lionsforest.domain.group.dto.request; + +import lombok.Getter; + +@Getter +public class GroupDeleteRequestDto { + private Long userId; +} 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..4729c4b --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupRequestDto.java @@ -0,0 +1,33 @@ +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 lombok.Getter; +import com.example.lionsforest.domain.group.Group; + +import java.time.LocalDateTime; + +@Getter +public class GroupRequestDto { + private Long userId; + private String title; + private GroupCategory category; + private Integer count; + private LocalDateTime meeting_at; + private String location; + private GroupState state; + + public Group toEntity(User leader){ + return Group.builder() + .title(this.title) + .category(this.category) + .count(this.count) + .meeting_at(this.meeting_at) + .location(this.location) + .state(this.state) + .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..79fe113 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupUpdateRequestDto.java @@ -0,0 +1,18 @@ +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 Long userId; + private String title; + private GroupCategory category; + private Integer count; + private LocalDateTime meeting_at; + private String location; + private GroupState state; +} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/request/ParticipationRequestDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/request/ParticipationRequestDto.java new file mode 100644 index 0000000..b7c5392 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/request/ParticipationRequestDto.java @@ -0,0 +1,8 @@ +package com.example.lionsforest.domain.group.dto.request; + +import lombok.Getter; + +@Getter +public class ParticipationRequestDto { + private Long userId; +} 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..7c99a72 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupResponseDto.java @@ -0,0 +1,35 @@ +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 java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class GroupResponseDto { + private Long id; + private String title; + private GroupCategory category; + private Integer count; + private LocalDateTime meeting_at; + private String location; + private GroupState state; + + public static GroupResponseDto fromEntity(Group group){ + return GroupResponseDto.builder() + .id(group.getId()) + .title(group.getTitle()) + .category(group.getCategory()) + .count(group.getCount()) + .meeting_at(group.getMeeting_at()) + .location(group.getLocation()) + .state(group.getState()) + .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..0f7803f --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/ParticipationResponseDto.java @@ -0,0 +1,33 @@ +package com.example.lionsforest.domain.group.dto.response; + +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.group.dto.request.ParticipationRequestDto; +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 String groupTitle; + private Long userId; + private String userName; + private LocalDateTime createdAt; + + public static ParticipationResponseDto fromEntity(Participation participation){ + return ParticipationResponseDto.builder() + .id(participation.getId()) + .groupId(participation.getGroup().getId()) + .groupTitle(participation.getGroup().getTitle()) + .userId(participation.getUser().getId()) + .userName(participation.getUser().getName()) + .createdAt(participation.getCreated_at()) + .build(); + } + +} 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..1b13035 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java @@ -0,0 +1,7 @@ +package com.example.lionsforest.domain.group.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import com.example.lionsforest.domain.group.Group; + +public interface GroupRepository extends JpaRepository { +} 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..31efc48 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java @@ -0,0 +1,21 @@ +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 countByGroupAndStatus(Group group, Participation); + + Optional findByGroupIdAndUserId(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..2448ad0 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -0,0 +1,99 @@ +package com.example.lionsforest.domain.group.service; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.dto.request.GroupDeleteRequestDto; +import com.example.lionsforest.domain.group.dto.request.GroupRequestDto; +import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; +import com.example.lionsforest.domain.group.repository.GroupRepository; +import com.example.lionsforest.domain.group.dto.request.GroupUpdateRequestDto; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class GroupService { + private final GroupRepository groupRepository; + private final UserRepository userRepository; + + + // 모임 개설 + @Transactional + public GroupResponseDto createGroup(GroupRequestDto dto){ + User user = userRepository.findById(dto.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + + Group group = dto.toEntity(user); + Group saved = groupRepository.save(group); + return new GroupResponseDto(saved.getId(), + saved.getTitle(), saved.getCategory(), + saved.getCount(), saved.getMeeting_at(), + saved.getLocation(), saved.getState()); + } + + // 모임 정보 전체 조회 + public List getAllGroup(){ + return groupRepository.findAll().stream() + .map(GroupResponseDto::fromEntity) + .toList(); + } + + + // 모임 정보 상세 조회 + public GroupResponseDto getGroupById(Long id) { + Group product = groupRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 모임이 존재하지 않습니다.")); + return GroupResponseDto.fromEntity(product); + } + + // 모임 수정 + @Transactional + public GroupResponseDto updateGroup(Long groupId, GroupUpdateRequestDto dto){ + + // 유저 조회 + User user = userRepository.findById(dto.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + + // 모임 조회 + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + + // 유저 권한 확인 + if(!group.getLeader().equals(user.getId())){ + throw new IllegalArgumentException("모임장만 모임을 수정할 수 있습니다."); + } + + // 모임 정보 수정 + group.update(dto.getTitle(), dto.getCategory(), dto.getCount(), dto.getMeeting_at(), dto.getLocation(), dto.getState()); + + return GroupResponseDto.fromEntity(group); + } + + // 모임 삭제 + @Transactional + public void deleteGroup(Long groupId, GroupDeleteRequestDto dto){ + + // 유저 조회 + User user = userRepository.findById(dto.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + + // 모임 조회 + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + + // 유저 권한 확인 + if(!group.getLeader().equals(user.getId())){ + throw new IllegalArgumentException("모임장만 모임을 수정할 수 있습니다."); + } + + //삭제 + groupRepository.delete(group); + } + + +} 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..5492dab --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -0,0 +1,71 @@ +package com.example.lionsforest.domain.group.service; + +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.repository.GroupRepository; +import com.example.lionsforest.domain.group.repository.ParticipationRepository; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.group.Participation; +import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; + +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ParticipationService { + private final ParticipationRepository participationRepository; + private final GroupRepository groupRepository; + private final UserRepository userRepository; + + // 모임 참여 + @Transactional + public ParticipationResponseDto joinGroup(Long groupId, Long userId){ + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + + // 중복 참여 체크 + if(participationRepository.existsByGroupAndUser(group, user)) { + throw new IllegalArgumentException("이미 참여 신청한 모임입니다."); + } + + // 인원 제한 체크 + long currentCount = participationRepository.countByGroupAndStatus( + group, ParticipationStatus.APPROVED); + if(currentCount >= group.getCount()) { + throw new IllegalArgumentException("모임 인원이 가득 찼습니다."); + } + + Participation participation = Participation.builder() + .group(group) + .user(user) + .build(); + + Participation saved = participationRepository.save(participation); + return ParticipationResponseDto.fromEntity(saved); + } + + // 모임 탈퇴 + @Transactional + public void leaveGroup(Long groupId, Long userId) { + Participation participation = participationRepository + .findByGroupIdAndUserId(groupId, userId) + .orElseThrow(() -> new IllegalArgumentException("참여하지 않은 모임입니다.")); + + participationRepository.delete(participation); + } + + // 모임 참여자 조회 + @Transactional(readOnly = true) + public List getParticipationsByGroupId(Long groupId){ + List participations = participationRepository.findByGroupId(groupId); + + return participations.stream() + .map(ParticipationResponseDto::fromEntity) + .toList(); + } +} From c17249b0f80fe70b669c96b27ccb9e843884214a Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Sat, 8 Nov 2025 21:43:45 +0900 Subject: [PATCH 03/78] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20API=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 47 ++++++++++++ .../dto/request/CommentRequestDto.java | 8 +++ .../dto/response/CommentResponseDto.java | 31 ++++++++ .../comment/repository/CommentRepository.java | 14 ++++ .../comment/service/CommentService.java | 72 +++++++++++++++++++ 5 files changed, 172 insertions(+) create mode 100644 src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java create mode 100644 src/main/java/com/example/lionsforest/domain/comment/dto/request/CommentRequestDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/comment/dto/response/CommentResponseDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java create mode 100644 src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java 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..07c64a5 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java @@ -0,0 +1,47 @@ +package com.example.lionsforest.domain.comment.controller; + +import com.example.lionsforest.domain.comment.dto.request.CommentRequestDto; +import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; +import com.example.lionsforest.domain.comment.service.CommentService; +import com.example.lionsforest.domain.group.dto.request.ParticipationRequestDto; +import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/comments") +public class CommentController { + private final CommentService commentService; + + // 댓글 생성 + @PostMapping("/{group_id}") + public ResponseEntity create(@PathVariable Long groupId + ,@RequestBody CommentRequestDto dto){ + return ResponseEntity.ok(commentService.createComment(groupId, dto.getUserId())); + } + + // 댓글 삭제 + @DeleteMapping("/{group_id}") + public ResponseEntity leaveGroup(@PathVariable Long groupId, + @RequestBody CommentRequestDto dto) { + commentService.deleteComment(groupId, dto.getUserId()); + return ResponseEntity.ok("댓글이 삭제 되었습니다."); + } + + // 모임별 댓글 조회 + @GetMapping("/{group_id}") + public ResponseEntity> getUser(@PathVariable Long groupId){ + return ResponseEntity.ok(commentService.getCommentsByGroupId(groupId)); + } +/* + // 유저별 댓글 조회 + @GetMapping("/{group_id}") + public ResponseEntity> getUser(@PathVariable Long groupId){ + return ResponseEntity.ok(commentService.getCommentsByGroupId(groupId)); + } +*/ +} 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..bad812a --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/dto/request/CommentRequestDto.java @@ -0,0 +1,8 @@ +package com.example.lionsforest.domain.comment.dto.request; + +import lombok.Getter; + +@Getter +public class CommentRequestDto { + private Long userId; +} 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..83bc25c --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/dto/response/CommentResponseDto.java @@ -0,0 +1,31 @@ +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 String groupTitle; + private Long userId; + private String userName; + private LocalDateTime createdAt; + + public static CommentResponseDto fromEntity(Comment comment){ + return CommentResponseDto.builder() + .id(comment.getComment_id()) + .groupId(comment.getGroup().getId()) + .groupTitle(comment.getGroup().getTitle()) + .userId(comment.getUser().getId()) + .userName(comment.getUser().getName()) + .createdAt(comment.getCreated_at()) + .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..a1babaf --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java @@ -0,0 +1,14 @@ +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; +import java.util.Optional; + +public interface CommentRepository 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/comment/service/CommentService.java b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java new file mode 100644 index 0000000..acddc44 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -0,0 +1,72 @@ +package com.example.lionsforest.domain.comment.service; + +import com.example.lionsforest.domain.comment.Comment; +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.repository.GroupRepository; +import com.example.lionsforest.domain.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CommentService { + private final CommentRepository commentRepository; + private final GroupRepository groupRepository; + private final UserRepository userRepository; + + // 댓글 생성 + @Transactional + public CommentResponseDto createComment(Long groupId, Long userId){ + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + + Comment comment = Comment.builder() + .group(group) + .user(user) + .build(); + + Comment saved = commentRepository.save(comment); + return CommentResponseDto.fromEntity(saved); + } + + // 댓글 삭제 + @Transactional + public void deleteComment(Long groupId, Long userId){ + Comment comment = commentRepository + .findByGroupIdAndUserId(groupId, userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다.")); + + commentRepository.delete(comment); + } + + // 모임별 댓글 조회 + @Transactional(readOnly = true) + public List getCommentsByGroupId(Long groupId){ + List comments = commentRepository.findByGroupId(groupId); + + return comments.stream() + .map(CommentResponseDto::fromEntity) + .toList(); + } +/* + // 유저별 댓글 조회 + @Transactional(readOnly = true) + public List getCommentsByUserId(Long userId){ + List comments = commentRepository.findByUserId(userId); + + return comments.stream() + .map(CommentResponseDto::fromEntity) + .toList(); + } + */ + + // 댓글 좋아요 생성/취소 + +} From 826b9e0257bc5e6b7e7671ed89b48f8a68bc0e26 Mon Sep 17 00:00:00 2001 From: Daecheol Lim Date: Sat, 8 Nov 2025 21:50:53 +0900 Subject: [PATCH 04/78] Create pull request template Add a pull request template for better PR management. --- .github/PULL_REQUEST_TEMPLATE.md | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4b11720 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ +[pull_request_template.md](https://github.com/user-attachments/files/23431873/pull_request_template.md) +## 📝 요약(Summary) + + + +## 🛠️ PR 유형 + +어떤 변경 사항이 있나요? + +- [ ] 새로운 기능 추가 +- [ ] 버그 수정 +- [ ] CSS 등 사용자 UI 디자인 변경 +- [ ] 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경) +- [ ] 코드 리팩토링 +- [ ] 주석 추가 및 수정 +- [ ] 문서 수정 +- [ ] 테스트 추가, 테스트 리팩토링 +- [ ] 빌드 부분 혹은 패키지 매니저 수정 +- [ ] 파일 혹은 폴더명 수정 +- [ ] 파일 혹은 폴더 삭제 + +## 📸스크린샷 (선택) + +## 💬 공유사항 to 리뷰어 + + + + + +## ✅ PR Checklist + +PR이 다음 요구 사항을 충족하는지 확인하세요. + +- [ ] 커밋 메시지 컨벤션에 맞게 작성했습니다. +- [ ] 변경 사항에 대한 테스트를 했습니다.(버그 수정/기능에 대한 테스트). From 3fa53362be16b29d1b026c26f45bff40d0764b5a Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Sat, 8 Nov 2025 22:47:10 +0900 Subject: [PATCH 05/78] =?UTF-8?q?feat:=20=ED=9B=84=EA=B8=B0=20API=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/repository/CommentRepository.java | 2 +- .../lionsforest/domain/review/Review.java | 5 ++ .../review/controller/ReviewController.java | 44 ++++++++++ .../review/dto/request/ReviewRequestDto.java | 8 ++ .../dto/response/ReviewResponseDto.java | 37 +++++++++ .../review/repository/ReviewRepository.java | 14 ++++ .../domain/review/service/ReviewService.java | 82 +++++++++++++++++++ 7 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java create mode 100644 src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewRequestDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewResponseDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/review/repository/ReviewRepository.java create mode 100644 src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java 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 index a1babaf..4e8783d 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java +++ b/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java @@ -7,7 +7,7 @@ import java.util.Optional; public interface CommentRepository extends JpaRepository { - Optional findByGroupIdAndUserId(Long groupId, Long UserId); + 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/Review.java b/src/main/java/com/example/lionsforest/domain/review/Review.java index c955f02..f5663a2 100644 --- a/src/main/java/com/example/lionsforest/domain/review/Review.java +++ b/src/main/java/com/example/lionsforest/domain/review/Review.java @@ -1,6 +1,7 @@ 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.AllArgsConstructor; @@ -25,6 +26,10 @@ public class Review extends BaseTimeEntity { @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; 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..c93d589 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java @@ -0,0 +1,44 @@ +package com.example.lionsforest.domain.review.controller; + +import com.example.lionsforest.domain.review.dto.request.ReviewRequestDto; +import com.example.lionsforest.domain.review.dto.response.ReviewResponseDto; +import com.example.lionsforest.domain.review.service.ReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/reviews") +public class ReviewController { + private final ReviewService reviewService; + + // 후기 생성 + @PostMapping("/{group_id}") + public ResponseEntity create(@PathVariable Long groupId, + @RequestBody ReviewRequestDto dto){ + return ResponseEntity.ok(reviewService.createReview(groupId, dto.getUserId())); + } + + // 후기 삭제 + @DeleteMapping("/{group_id}") + public ResponseEntity deleteReview(@PathVariable Long groupId, + @RequestBody ReviewRequestDto dto){ + reviewService.deleteReview(groupId, dto.getUserId()); + return ResponseEntity.ok("후기가 삭제 되었습니다.") + } + + // 모임별 후기 조회 + @GetMapping("/{group_id}") + public ResponseEntity> getReviewByGroupId(@PathVariable Long groupId){ + return ResponseEntity.ok(reviewService.getReviewByGroupId(groupId)); + } + + // 특정 유저의 후기 전체 조회 + @GetMapping("/{user_id}") + public ResponseEntity> getReviewByUserId(@PathVariable Long userId){ + return ResponseEntity.ok(reviewService.getReviewByUserId(userId)); + } +} 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..95880c8 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewRequestDto.java @@ -0,0 +1,8 @@ +package com.example.lionsforest.domain.review.dto.request; + +import lombok.Getter; + +@Getter +public class ReviewRequestDto { + private Long userId; +} 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..3fc55c2 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewResponseDto.java @@ -0,0 +1,37 @@ +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 String groupTitle; + private Long userId; + private String userName; + private String content; + private Integer score; + private LocalDateTime createdAt; + + public static ReviewResponseDto fromEntity(Review review){ + return ReviewResponseDto.builder() + .id(review.getReview_id()) + .groupId(review.getGroup().getId()) + .groupTitle(review.getGroup().getTitle()) + .userId(review.getUser().getId()) + .userName(review.getUser().getName()) + .content(review.getContent()) + .score(review.getScore()) + .createdAt(review.getCreated_at()) + .build(); + } +} 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..bd0bed1 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -0,0 +1,82 @@ +package com.example.lionsforest.domain.review.service; + +import com.example.lionsforest.domain.comment.Comment; +import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; +import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.repository.GroupRepository; +import com.example.lionsforest.domain.review.Review; +import com.example.lionsforest.domain.review.dto.response.ReviewResponseDto; +import com.example.lionsforest.domain.review.repository.ReviewRepository; +import com.example.lionsforest.domain.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReviewService { + private final ReviewRepository reviewRepository; + private final GroupRepository groupRepository; + private final UserRepository userRepository; + + // 후기 생성 + @Transactional + public ReviewResponseDto createReview(Long groupId, Long userId){ + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + + Review review = Review.builder() + .group(group) + .user(user) + .build(); + + Review saved = ReviewRepository.save(review); + return ReviewResponseDto.fromEntity(saved); + } + + // 후기 삭제 + @Transactional + public void deleteReview(Long groupId, Long userId){ + Review review = reviewRepository + .findByGroupIdAndUserId(groupId, userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 후기입니다.")); + + reviewRepository.delete(review); + } + + //후기 수정 + + /* + // 개별 후기 조회 + @Transactional(readOnly = true) + public ReviewResponseDto getReviewByUserId(Long userId){ + } + */ + + // 모임별 후기 조회 + @Transactional(readOnly = true) + public List getReviewByGroupId(Long groupId){ + List reviews = reviewRepository.findByGroupId(groupId); + + return reviews.stream() + .map(ReviewResponseDto::fromEntity) + .toList(); + } + + // 특정 유저의 후기 전체 조회 + @Transactional(readOnly = true) + public List getReviewByUserId(Long userId){ + List reviews = reviewRepository.findByUserId(userId); + + return reviews.stream() + .map(ReviewResponseDto::fromEntity) + .toList(); + } + + + +} From fb0c08fa6b648b58378c3a5fbdc065145ed35a81 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Sun, 9 Nov 2025 20:44:08 +0900 Subject: [PATCH 06/78] =?UTF-8?q?feat:=20google=20oauth2.0=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- build.gradle | 10 ++ .../example/lionsforest/domain/user/User.java | 16 +++ .../user/controller/AuthController.java | 29 ++++++ .../domain/user/dto/LoginRequestDTO.java | 9 ++ .../domain/user/dto/LoginResponseDTO.java | 15 +++ .../domain/user/dto/TokenResponseDTO.java | 12 +++ .../domain/user/dto/UserInfoDTO.java | 26 +++++ .../domain/user/dto/UserInfoResponseDTO.java | 29 ++++++ .../domain/user/dto/UserUpdateRequestDTO.java | 11 +++ .../user/repository/UserRepository.java | 14 +++ .../domain/user/service/AuthService.java | 66 +++++++++++++ .../domain/user/service/UserService.java | 61 ++++++++++++ .../global/component/GoogleTokenVerifier.java | 59 +++++++++++ .../component/MemberWhitelistValidator.java | 64 ++++++++++++ .../global/config/SecurityConfig.java | 47 +++++++++ .../global/jwt/JwtTokenProvider.java | 98 +++++++++++++++++++ 17 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/LoginRequestDTO.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/LoginResponseDTO.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/TokenResponseDTO.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/UserInfoDTO.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/UserInfoResponseDTO.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/repository/UserRepository.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/service/AuthService.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/service/UserService.java create mode 100644 src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java create mode 100644 src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java create mode 100644 src/main/java/com/example/lionsforest/global/config/SecurityConfig.java create mode 100644 src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java diff --git a/.gitignore b/.gitignore index ef83185..6aaaf50 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ out/ .vscode/ #Spring 설정 파일 -application.yml \ No newline at end of file +application.yml +src/main/resources/application-secret.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle index 9cab71f..010d1ab 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,16 @@ dependencies { 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 + // Google OAuth2/IdToken 검증 라이브러리 + implementation 'com.google.api-client:google-api-client:2.0.0' + implementation 'com.google.http-client:google-http-client-jackson2:1.44.1' + // 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' } tasks.named('test') { diff --git a/src/main/java/com/example/lionsforest/domain/user/User.java b/src/main/java/com/example/lionsforest/domain/user/User.java index fe99ef9..47735cc 100644 --- a/src/main/java/com/example/lionsforest/domain/user/User.java +++ b/src/main/java/com/example/lionsforest/domain/user/User.java @@ -5,12 +5,14 @@ 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.domain.user.dto.UserInfoResponseDTO; import com.example.lionsforest.global.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.HashSet; @@ -90,4 +92,18 @@ public class User extends BaseTimeEntity { @ManyToMany(mappedBy = "liked_users") private Set liked_by_users = new HashSet<>(); + //메서드 + // 유저 프로필 수정 + public void updateProfile(String nickname, String bio, String profile_photo) { + //null이 아닐 때만 필드 업데이트 + if(nickname != null & nickname.isBlank()){ + this.nickname = nickname; + } + if(bio != null & bio.isBlank()){ + this.bio = bio; + } + if(profile_photo != null & profile_photo.isBlank()){ + this.profile_photo = profile_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..82f90f2 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java @@ -0,0 +1,29 @@ +package com.example.lionsforest.domain.user.controller; + +import com.example.lionsforest.domain.user.dto.LoginRequestDTO; +import com.example.lionsforest.domain.user.dto.LoginResponseDTO; +import com.example.lionsforest.domain.user.service.AuthService; +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 +public class AuthController { + + private final AuthService authService; + + //구글 로그인(회원가입) + @PostMapping("/google") + public ResponseEntity googleLogin( + @Valid @RequestBody LoginRequestDTO request) { + + LoginResponseDTO response = authService.googleLoginOrRegister(request); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/LoginRequestDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/LoginRequestDTO.java new file mode 100644 index 0000000..3df7f6c --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/LoginRequestDTO.java @@ -0,0 +1,9 @@ +package com.example.lionsforest.domain.user.dto; + +import com.example.lionsforest.domain.user.User; +import lombok.Getter; + +@Getter +public class LoginRequestDTO { + private String idToken; +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/LoginResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/LoginResponseDTO.java new file mode 100644 index 0000000..c5502e3 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/LoginResponseDTO.java @@ -0,0 +1,15 @@ +package com.example.lionsforest.domain.user.dto; + +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; +} diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/TokenResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/TokenResponseDTO.java new file mode 100644 index 0000000..880a6b9 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/TokenResponseDTO.java @@ -0,0 +1,12 @@ +package com.example.lionsforest.domain.user.dto; + +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/UserInfoDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/UserInfoDTO.java new file mode 100644 index 0000000..cb1af75 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/UserInfoDTO.java @@ -0,0 +1,26 @@ +package com.example.lionsforest.domain.user.dto; + +import com.example.lionsforest.domain.user.User; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import lombok.Builder; +import lombok.Getter; + +// 유저 로그인에 사용 +// GoogleTokenVerifier가 반환하는 내부 전용 DTO +@Getter +@Builder +public class UserInfoDTO { + 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/UserInfoResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/UserInfoResponseDTO.java new file mode 100644 index 0000000..78e91b9 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/UserInfoResponseDTO.java @@ -0,0 +1,29 @@ +package com.example.lionsforest.domain.user.dto; + +import com.example.lionsforest.domain.user.User; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; +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/UserUpdateRequestDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java new file mode 100644 index 0000000..fd570e7 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java @@ -0,0 +1,11 @@ +package com.example.lionsforest.domain.user.dto; + +import lombok.Getter; + +// 유저 정보 수정에 사용 +@Getter +public class UserUpdateRequestDTO { + private String nickname; + private String bio; + private String profile_photo; +} 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..b0529a6 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java @@ -0,0 +1,66 @@ +package com.example.lionsforest.domain.user.service; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.dto.LoginRequestDTO; +import com.example.lionsforest.domain.user.dto.LoginResponseDTO; +import com.example.lionsforest.domain.user.dto.TokenResponseDTO; +import com.example.lionsforest.domain.user.dto.UserInfoDTO; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.component.GoogleTokenVerifier; +import com.example.lionsforest.global.component.MemberWhitelistValidator; +import com.example.lionsforest.global.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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; + + public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { + + UserInfoDTO googleUserInfo = googleTokenVerifier.verify(request.getIdToken()); + String name = googleUserInfo.getName(); + String email = googleUserInfo.getEmail(); + + if (!whitelistValidator.isMember(name, email)) { + throw new SecurityException("동아리 부원 명단에 존재하지 않거나 정보가 일치하지 않습니다."); + } + + Optional optionalUser = userRepository.findByEmail(email); + boolean isNewUser = false; + User user; + + if (optionalUser.isEmpty()) { + user = googleUserInfo.toEntity(); + userRepository.save(user); + isNewUser = true; + System.out.println("새 유저 생성!"); + } else { + user = optionalUser.get(); + System.out.println("이미 존재하는 유저"); + } + + // 6. JWT 토큰 생성 (반환 타입이 TokenResponse DTO임) + TokenResponseDTO tokens = jwtTokenProvider.createTokens(user.getId(), user.getEmail()); + + // 7. 응답 DTO 생성 + return LoginResponseDTO.builder() + .id(user.getId()) + .accessToken(tokens.getAccessToken()) // TokenResponse DTO의 getter + .refreshToken(tokens.getRefreshToken()) // TokenResponse DTO의 getter + .isNewUser(isNewUser) + .nickname(user.getNickname()) + .build(); + } +} \ No newline at end of file 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..31a9f55 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/service/UserService.java @@ -0,0 +1,61 @@ +package com.example.lionsforest.domain.user.service; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.dto.UserInfoResponseDTO; +import com.example.lionsforest.domain.user.dto.UserUpdateRequestDTO; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.nimbusds.openid.connect.sdk.UserInfoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // 기본적으로 읽기 전용 +public class UserService { + + private final UserRepository userRepository; + + //유저 목록 전체 조회 + 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 && + !request.getNickname().equals(user.getNickname()) && + userRepository.existsByNickname(request.getNickname())) { + throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); + } + + // User 엔티티 내부의 update 메서드 호출 (JPA 변경 감지) + user.updateProfile( + request.getNickname(), + request.getBio(), + request.getProfile_photo() + ); + + return UserInfoResponseDTO.from(user); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다. ID: " + userId)); + } +} \ No newline at end of file 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..9cebbb5 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java @@ -0,0 +1,59 @@ +package com.example.lionsforest.global.component; + +import com.example.lionsforest.domain.user.dto.UserInfoDTO; +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 UserInfoDTO verify(String idToken) { + try { + if (clientId == null || clientId.isBlank() || clientId.contains("YOUR_GOOGLE_CLIENT_ID")) { + throw new IllegalArgumentException("Google Client ID가 application.yml에 설정되지 않았습니다."); + } + + GoogleIdToken googleIdToken = verifier.verify(idToken); + if (googleIdToken == null) { + throw new SecurityException("유효하지 않은 구글 ID 토큰입니다."); + } + + 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 SecurityException("구글 토큰에서 이메일 또는 이름 정보를 가져올 수 없습니다."); + } + + return UserInfoDTO.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..fc7de4d --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java @@ -0,0 +1,64 @@ +package com.example.lionsforest.global.component; + +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()); + }catch(IOException e){ + log.error("Failed to load whitelist", e); + } + } + + //구글 정보와 whitelist 일치하는지 검증 + public boolean isMember(String googleName, String googleEmail) { + //whitelist에 이메일 키 있는지 검증 + if(!whitelist.containsKey(googleEmail)){ + log.warn("User {} does not have a whitelisted member", googleEmail); + return false; + } + + //이름 비교 + String whitelistName = whitelist.get(googleEmail); + boolean isMatch = googleName.equals(whitelistName); + if(!isMatch){ + log.warn("User {} does not have a whitelisted member", googleEmail); + } + return isMatch; + } + +} + + 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..d59eec6 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -0,0 +1,47 @@ +package com.example.lionsforest.global.config; + + +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; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // 1. CSRF 비활성화 + .csrf(AbstractHttpConfigurer::disable) + + // 2. 세션 관리 비활성화 + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // 3. 폼 로그인/HTTP Basic 인증 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + + // 4. OAuth2 로그인 페이지 비활성화 + // 이걸 꺼야 /auth/google 요청이 컨트롤러로 감 + .oauth2Login(AbstractHttpConfigurer::disable) + + // 5. API 엔드포인트별 접근 권한 설정 + .authorizeHttpRequests(auth -> auth + // "/auth/**" 경로는 인증 없이 무조건 통과(permitAll) + .requestMatchers("/auth/**").permitAll() + + // 추후 "/api/**" 등 다른 엔드포인트는 인증(JWT)이 필요하도록 설정 + .anyRequest().authenticated() + ); + + // 추후 JWT 필터 추가 필요 + + return http.build(); + } +} \ 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..6e82d8d --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java @@ -0,0 +1,98 @@ +package com.example.lionsforest.global.jwt; + +import com.example.lionsforest.domain.user.dto.TokenResponseDTO; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@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); + String refreshToken = generateToken(userId, email, refreshTokenValidityInMs); // Refresh Token에도 기본 정보 포함 + + return TokenResponseDTO.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // 실제 토큰을 생성하는 메서드 + private String generateToken(Long userId, String email, long validityMs) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityMs); + + return Jwts.builder() + .setSubject(String.valueOf(userId)) // 토큰의 주체 (유저 ID) + .claim("email", email) // 커스텀 클레임 (이메일) + .setIssuedAt(now) // 발급 시간 + .setExpiration(validity) // 만료 시간 + .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(); + } + } +} From 58caba80556f1fff29c43d7b3f3a2402dba2670b Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Sun, 9 Nov 2025 20:52:24 +0900 Subject: [PATCH 07/78] =?UTF-8?q?chore:=20.gitignore=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6aaaf50..c123ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ out/ #Spring 설정 파일 application.yml -src/main/resources/application-secret.yml \ No newline at end of file +src/main/resources/application-secret.yml +src/main/resources/members.txt \ No newline at end of file From ab6a49550c52be2ea90c3ae9563db53f13c8f67b Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Sun, 9 Nov 2025 21:05:30 +0900 Subject: [PATCH 08/78] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/CommentResponseDto.java | 2 +- .../comment/service/CommentService.java | 1 + .../lionsforest/domain/group/Group.java | 10 ++-- .../group/controller/GroupController.java | 13 +++-- .../group/dto/request/GroupRequestDto.java | 12 ++--- .../group/dto/response/GroupResponseDto.java | 8 +-- .../response/ParticipationResponseDto.java | 2 +- .../repository/GroupPhotoRepository.java | 11 ++++ .../domain/group/service/GroupService.java | 45 +++++++++++++---- .../group/service/LocalUploadService.java | 50 +++++++++++++++++++ .../group/service/ParticipationService.java | 5 +- .../review/controller/ReviewController.java | 2 +- .../dto/response/ReviewResponseDto.java | 2 +- .../domain/review/service/ReviewService.java | 3 +- .../user/repository/UserRepository.java | 7 +++ .../global/common/BaseTimeEntity.java | 4 +- 16 files changed, 138 insertions(+), 39 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/domain/group/repository/GroupPhotoRepository.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/repository/UserRepository.java 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 index 83bc25c..2a08291 100644 --- 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 @@ -25,7 +25,7 @@ public static CommentResponseDto fromEntity(Comment comment){ .groupTitle(comment.getGroup().getTitle()) .userId(comment.getUser().getId()) .userName(comment.getUser().getName()) - .createdAt(comment.getCreated_at()) + .createdAt(comment.getCreatedAt()) .build(); } } 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 index acddc44..78aa265 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -3,6 +3,7 @@ import com.example.lionsforest.domain.comment.Comment; import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; import com.example.lionsforest.domain.comment.repository.CommentRepository; +import com.example.lionsforest.domain.user.repository.UserRepository; import com.example.lionsforest.domain.group.Group; import com.example.lionsforest.domain.group.repository.GroupRepository; import com.example.lionsforest.domain.user.User; diff --git a/src/main/java/com/example/lionsforest/domain/group/Group.java b/src/main/java/com/example/lionsforest/domain/group/Group.java index 0b66cb8..edd4052 100644 --- a/src/main/java/com/example/lionsforest/domain/group/Group.java +++ b/src/main/java/com/example/lionsforest/domain/group/Group.java @@ -33,10 +33,10 @@ public class Group extends BaseTimeEntity { private GroupCategory category; @Column(nullable = false) - private Integer count; + private Integer capacity; // 모집 정원 @Column(nullable = false) - private LocalDateTime meeting_at; + private LocalDateTime meetingAt; private String location; @@ -69,11 +69,11 @@ public class Group extends BaseTimeEntity { @OneToMany(mappedBy = "group", cascade = CascadeType.ALL) private List reviews = new ArrayList<>(); - public void update(String title, GroupCategory category, Integer count, LocalDateTime meeting_at, String location, GroupState state) { + public void update(String title, GroupCategory category, Integer capacity, LocalDateTime meetingAt, String location, GroupState state) { this.title = title; this.category = category; - this.count = count; - this.meeting_at = meeting_at; + this.capacity = capacity; + this.meetingAt = meetingAt; this.location = location; this.state = state; } 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 index b72d446..aaf4271 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -8,21 +8,26 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + import java.util.List; @RestController @RequiredArgsConstructor -@RequestMapping("/groups") +@RequestMapping("/api/groups") public class GroupController { private final GroupService groupService; // 모임 개설 @PostMapping - public ResponseEntity createGroup(@RequestBody GroupRequestDto dto){ + public ResponseEntity createGroup(@RequestBody GroupRequestDto dto, + @RequestPart(value = "photos", required = false) List photos, + @PathVariable){ + User user = return ResponseEntity.ok(groupService.createGroup(dto)); } - +/* // 모임 정보 전체 조회 @GetMapping public ResponseEntity> getAllGroups(){ @@ -48,5 +53,5 @@ public ResponseEntity deleteGroup(@PathVariable Long id, @RequestBody GroupDeleteRequestDto dto){ return ResponseEntity.ok("모임이 성공적으로 삭제되었습니다."); } - +*/ } 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 index 4729c4b..6f22d98 100644 --- 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 @@ -10,22 +10,20 @@ @Getter public class GroupRequestDto { - private Long userId; private String title; private GroupCategory category; - private Integer count; - private LocalDateTime meeting_at; + private Integer capacity; + private LocalDateTime meetingAt; private String location; - private GroupState state; public Group toEntity(User leader){ return Group.builder() .title(this.title) .category(this.category) - .count(this.count) - .meeting_at(this.meeting_at) + .capacity(this.capacity) + .meetingAt(this.meetingAt) .location(this.location) - .state(this.state) + .state(GroupState.OPEN) // 기본 설정 : 모집중 .leader(leader) .build(); } 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 index 7c99a72..d6da62a 100644 --- 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 @@ -16,8 +16,8 @@ public class GroupResponseDto { private Long id; private String title; private GroupCategory category; - private Integer count; - private LocalDateTime meeting_at; + private Integer capacity; + private LocalDateTime meetingAt; private String location; private GroupState state; @@ -26,8 +26,8 @@ public static GroupResponseDto fromEntity(Group group){ .id(group.getId()) .title(group.getTitle()) .category(group.getCategory()) - .count(group.getCount()) - .meeting_at(group.getMeeting_at()) + .capacity(group.getCapacity()) + .meetingAt(group.getMeetingAt()) .location(group.getLocation()) .state(group.getState()) .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 index 0f7803f..f928dab 100644 --- 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 @@ -26,7 +26,7 @@ public static ParticipationResponseDto fromEntity(Participation participation){ .groupTitle(participation.getGroup().getTitle()) .userId(participation.getUser().getId()) .userName(participation.getUser().getName()) - .createdAt(participation.getCreated_at()) + .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..472bd07 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupPhotoRepository.java @@ -0,0 +1,11 @@ +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; + +public interface GroupPhotoRepository extends JpaRepository { + List findAllByGroup(Group group); +} 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 index 2448ad0..227bc5a 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -6,37 +6,62 @@ import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; 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.user.User; -import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.domain.group.service.LocalUploadService; + import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.util.ArrayList; import java.util.List; @Service @RequiredArgsConstructor public class GroupService { private final GroupRepository groupRepository; - private final UserRepository userRepository; - + private final GroupPhotoRepository groupPhotoRepository; // (의존성 주입) + private final LocalUploadService s3UploadService; // 파일업로드 // 모임 개설 @Transactional - public GroupResponseDto createGroup(GroupRequestDto dto){ - User user = userRepository.findById(dto.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); - + public GroupResponseDto createGroup(GroupRequestDto dto, + List photos, + User user){ + // Group Entity 먼저 생성(ID 확보) Group group = dto.toEntity(user); Group saved = groupRepository.save(group); + + 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, "s3폴더경로"); + // GroupPhoto 엔티티 생성 + GroupPhoto groupPhoto = GroupPhoto.builder() + .group(saved) // 저장된 Group 객체 + .photo(photoUrl) // S3에서 반환된 URL + .photo_order(i) // 사진 순서 (0부터 시작) + .build(); + + groupPhotos.add(groupPhoto); + } + // GroupPhoto 리스트를 DB에 한 번에 저장 (Batch Insert) + groupPhotoRepository.saveAll(groupPhotos); + return new GroupResponseDto(saved.getId(), saved.getTitle(), saved.getCategory(), - saved.getCount(), saved.getMeeting_at(), + saved.getCapacity(), saved.getMeetingAt(), saved.getLocation(), saved.getState()); } - // 모임 정보 전체 조회 +/* // 모임 정보 전체 조회 public List getAllGroup(){ return groupRepository.findAll().stream() .map(GroupResponseDto::fromEntity) @@ -95,5 +120,5 @@ public void deleteGroup(Long groupId, GroupDeleteRequestDto dto){ groupRepository.delete(group); } - +*/ } diff --git a/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java b/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java new file mode 100644 index 0000000..967208d --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java @@ -0,0 +1,50 @@ +package com.example.lionsforest.domain.group.service; + +import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.util.UUID; + +@Slf4j +@Service +public class LocalUploadService { + @Value("${upload.dir}") // application.yml에서 설정한 경로 + private String uploadDir; + + public String upload(MultipartFile file, String dirName) { + try { + // 1. 파일 원본 이름 + String originalFilename = file.getOriginalFilename(); + // 2. 고유한 파일 이름 생성 (UUID) + String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + String uniqueFileName = UUID.randomUUID().toString() + extension; + + // 3. 저장할 전체 경로 (예: ./uploads/group-photos/uuid.jpg) + String savePath = uploadDir + dirName + File.separator + uniqueFileName; + + // 4. 로컬 디렉토리 생성 (없으면) + File saveDir = new File(uploadDir + dirName); + if (!saveDir.exists()) { + saveDir.mkdirs(); // mkdirs()로 중간 경로까지 모두 생성 + } + + // 5. 파일 저장 + file.transferTo(new File(savePath)); + + log.info("로컬 파일 저장 성공: {}", savePath); + + // 6. [중요] 웹에서 접근 가능한 URL 반환 + // (예: /uploads/group-photos/uuid.jpg) + // WebConfig 설정이 필요합니다. (다음 단계 참고) + return "/uploads/" + dirName + "/" + uniqueFileName; + + } catch (IOException e) { + log.error("로컬 파일 업로드 실패", e); + throw new RuntimeException("로컬 파일 업로드 중 오류가 발생했습니다.", e); + } + } +} 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 index 5492dab..73656c9 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -3,6 +3,7 @@ import com.example.lionsforest.domain.group.Group; import com.example.lionsforest.domain.group.repository.GroupRepository; import com.example.lionsforest.domain.group.repository.ParticipationRepository; +import com.example.lionsforest.domain.user.repository.UserRepository; import com.example.lionsforest.domain.user.User; import com.example.lionsforest.domain.group.Participation; import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; @@ -32,14 +33,14 @@ public ParticipationResponseDto joinGroup(Long groupId, Long userId){ if(participationRepository.existsByGroupAndUser(group, user)) { throw new IllegalArgumentException("이미 참여 신청한 모임입니다."); } - +/* // 인원 제한 체크 long currentCount = participationRepository.countByGroupAndStatus( group, ParticipationStatus.APPROVED); if(currentCount >= group.getCount()) { throw new IllegalArgumentException("모임 인원이 가득 찼습니다."); } - +*/ Participation participation = Participation.builder() .group(group) .user(user) 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 index c93d589..0f0fb46 100644 --- a/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java +++ b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java @@ -27,7 +27,7 @@ public ResponseEntity create(@PathVariable Long groupId, public ResponseEntity deleteReview(@PathVariable Long groupId, @RequestBody ReviewRequestDto dto){ reviewService.deleteReview(groupId, dto.getUserId()); - return ResponseEntity.ok("후기가 삭제 되었습니다.") + return ResponseEntity.ok("후기가 삭제 되었습니다."); } // 모임별 후기 조회 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 index 3fc55c2..52abc8f 100644 --- 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 @@ -31,7 +31,7 @@ public static ReviewResponseDto fromEntity(Review review){ .userName(review.getUser().getName()) .content(review.getContent()) .score(review.getScore()) - .createdAt(review.getCreated_at()) + .createdAt(review.getCreatedAt()) .build(); } } 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 index bd0bed1..8202539 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -8,6 +8,7 @@ import com.example.lionsforest.domain.review.dto.response.ReviewResponseDto; import com.example.lionsforest.domain.review.repository.ReviewRepository; import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +35,7 @@ public ReviewResponseDto createReview(Long groupId, Long userId){ .user(user) .build(); - Review saved = ReviewRepository.save(review); + Review saved = reviewRepository.save(review); return ReviewResponseDto.fromEntity(saved); } 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..95e4473 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/repository/UserRepository.java @@ -0,0 +1,7 @@ +package com.example.lionsforest.domain.user.repository; + +import com.example.lionsforest.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/lionsforest/global/common/BaseTimeEntity.java b/src/main/java/com/example/lionsforest/global/common/BaseTimeEntity.java index 8fa00ee..f3676ac 100644 --- a/src/main/java/com/example/lionsforest/global/common/BaseTimeEntity.java +++ b/src/main/java/com/example/lionsforest/global/common/BaseTimeEntity.java @@ -16,9 +16,9 @@ public class BaseTimeEntity { @CreatedDate @Column(updatable = false, name = "created_at") - private LocalDateTime created_at; + private LocalDateTime createdAt; @LastModifiedDate @Column(name = "updated_at") - private LocalDateTime updated_at; + private LocalDateTime updatedAt; } From a468ae558d46396ab8719ec0c87f1d8f20c1da96 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Sun, 9 Nov 2025 21:10:51 +0900 Subject: [PATCH 09/78] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/domain/comment/service/CommentService.java | 1 - .../domain/group/service/ParticipationService.java | 1 - .../lionsforest/domain/review/service/ReviewService.java | 3 --- .../lionsforest/domain/user/repository/UserRepository.java | 7 ------- 4 files changed, 12 deletions(-) delete mode 100644 src/main/java/com/example/lionsforest/domain/user/repository/UserRepository.java 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 index 78aa265..acddc44 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -3,7 +3,6 @@ import com.example.lionsforest.domain.comment.Comment; import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; import com.example.lionsforest.domain.comment.repository.CommentRepository; -import com.example.lionsforest.domain.user.repository.UserRepository; import com.example.lionsforest.domain.group.Group; import com.example.lionsforest.domain.group.repository.GroupRepository; import com.example.lionsforest.domain.user.User; 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 index 73656c9..9f6e552 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -3,7 +3,6 @@ import com.example.lionsforest.domain.group.Group; import com.example.lionsforest.domain.group.repository.GroupRepository; import com.example.lionsforest.domain.group.repository.ParticipationRepository; -import com.example.lionsforest.domain.user.repository.UserRepository; import com.example.lionsforest.domain.user.User; import com.example.lionsforest.domain.group.Participation; import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; 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 index 8202539..de5460a 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -1,14 +1,11 @@ package com.example.lionsforest.domain.review.service; -import com.example.lionsforest.domain.comment.Comment; -import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; import com.example.lionsforest.domain.group.Group; import com.example.lionsforest.domain.group.repository.GroupRepository; import com.example.lionsforest.domain.review.Review; import com.example.lionsforest.domain.review.dto.response.ReviewResponseDto; import com.example.lionsforest.domain.review.repository.ReviewRepository; import com.example.lionsforest.domain.user.User; -import com.example.lionsforest.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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 deleted file mode 100644 index 95e4473..0000000 --- a/src/main/java/com/example/lionsforest/domain/user/repository/UserRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.lionsforest.domain.user.repository; - -import com.example.lionsforest.domain.user.User; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UserRepository extends JpaRepository { -} From 22b6f56f3c237e96b95779705928def48ff34b00 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Sun, 9 Nov 2025 21:18:57 +0900 Subject: [PATCH 10/78] =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/domain/group/service/GroupService.java | 5 +++-- .../domain/group/service/ParticipationService.java | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) 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 index 227bc5a..72cf02a 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -24,8 +24,8 @@ @RequiredArgsConstructor public class GroupService { private final GroupRepository groupRepository; - private final GroupPhotoRepository groupPhotoRepository; // (의존성 주입) - private final LocalUploadService s3UploadService; // 파일업로드 + private final GroupPhotoRepository groupPhotoRepository; + private final LocalUploadService s3UploadService; // 파일업로드(지금은 우선 로컬로) // 모임 개설 @Transactional @@ -54,6 +54,7 @@ public GroupResponseDto createGroup(GroupRequestDto dto, } // GroupPhoto 리스트를 DB에 한 번에 저장 (Batch Insert) groupPhotoRepository.saveAll(groupPhotos); + } return new GroupResponseDto(saved.getId(), saved.getTitle(), saved.getCategory(), 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 index 9f6e552..755ceb5 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -4,6 +4,7 @@ import com.example.lionsforest.domain.group.repository.GroupRepository; import com.example.lionsforest.domain.group.repository.ParticipationRepository; 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; From d2d089cecad951e4c7a63b789d04f8a24d1de134 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Mon, 10 Nov 2025 04:18:28 +0900 Subject: [PATCH 11/78] =?UTF-8?q?refactor:=20API=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/domain/comment/Comment.java | 4 +- .../comment/controller/CommentController.java | 38 ++++-- .../dto/request/CommentRequestDto.java | 3 + .../dto/response/CommentResponseDto.java | 8 +- .../comment/repository/CommentRepository.java | 4 +- .../comment/service/CommentService.java | 43 +++++-- .../lionsforest/domain/group/Group.java | 15 +-- .../group/controller/GroupController.java | 80 +++++++++--- .../controller/ParticipationController.java | 18 ++- .../group/dto/request/GroupRequestDto.java | 1 + .../dto/request/GroupUpdateRequestDto.java | 4 +- .../response/GroupGetDetailResponseDto.java | 45 +++++++ .../dto/response/GroupGetResponseDto.java | 46 +++++++ .../response/ParticipationResponseDto.java | 2 - .../domain/group/dto/response/PhotoDto.java | 17 +++ .../repository/GroupPhotoRepository.java | 1 + .../group/repository/GroupRepository.java | 7 +- .../repository/ParticipationRepository.java | 3 +- .../domain/group/service/GroupService.java | 116 ++++++++++++++---- .../group/service/LocalUploadService.java | 37 ++++++ .../group/service/ParticipationService.java | 18 ++- .../review/dto/request/ReviewRequestDto.java | 3 +- 22 files changed, 411 insertions(+), 102 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetDetailResponseDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetResponseDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/response/PhotoDto.java diff --git a/src/main/java/com/example/lionsforest/domain/comment/Comment.java b/src/main/java/com/example/lionsforest/domain/comment/Comment.java index f8dfd1d..5ed17d9 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/Comment.java +++ b/src/main/java/com/example/lionsforest/domain/comment/Comment.java @@ -20,7 +20,7 @@ public class Comment extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long comment_id; + private Long commentId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "group_id", nullable = false) @@ -33,8 +33,6 @@ public class Comment extends BaseTimeEntity { @Column(nullable = false) private String content; - private Integer likes; - //이 댓글을 좋아요한 유저 @Builder.Default @ManyToMany(mappedBy = "liked_comments") 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 index 07c64a5..e317f49 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java +++ b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java @@ -5,38 +5,62 @@ import com.example.lionsforest.domain.comment.service.CommentService; import com.example.lionsforest.domain.group.dto.request.ParticipationRequestDto; import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; @RestController @RequiredArgsConstructor -@RequestMapping("/comments") +@RequestMapping("/api/comments") public class CommentController { private final CommentService commentService; + private final UserRepository userRepository; // 댓글 생성 @PostMapping("/{group_id}") - public ResponseEntity create(@PathVariable Long groupId + public ResponseEntity createComment(@PathVariable Long groupId ,@RequestBody CommentRequestDto dto){ - return ResponseEntity.ok(commentService.createComment(groupId, dto.getUserId())); + User user = userRepository.findById(dto.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + + return ResponseEntity.ok(commentService.createComment(groupId, dto, user)); } // 댓글 삭제 - @DeleteMapping("/{group_id}") - public ResponseEntity leaveGroup(@PathVariable Long groupId, + @DeleteMapping("/{comment_id}") + public ResponseEntity deleteComment(@PathVariable Long commentId, @RequestBody CommentRequestDto dto) { - commentService.deleteComment(groupId, dto.getUserId()); + User user = userRepository.findById(dto.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + + commentService.deleteComment(commentId, user); return ResponseEntity.ok("댓글이 삭제 되었습니다."); } // 모임별 댓글 조회 @GetMapping("/{group_id}") - public ResponseEntity> getUser(@PathVariable Long groupId){ + public ResponseEntity> getCommentByGroup(@PathVariable Long groupId){ return ResponseEntity.ok(commentService.getCommentsByGroupId(groupId)); } + + // 특정 댓글 좋아요 (Toggle) + @PostMapping("/{comment_id}/like") + public ResponseEntity toggleLike( + @PathVariable Long commentId, + @RequestBody Map requestBody + ) { + Long userId = requestBody.get("userId"); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + + String message = commentService.toggleLike(commentId, user); + return ResponseEntity.ok(message); + } /* // 유저별 댓글 조회 @GetMapping("/{group_id}") 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 index bad812a..09fda86 100644 --- 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 @@ -1,8 +1,11 @@ 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; private Long userId; } 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 index 2a08291..fe5d87f 100644 --- 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 @@ -13,18 +13,20 @@ public class CommentResponseDto { private Long id; private Long groupId; - private String groupTitle; private Long userId; private String userName; + private String content; + private int likeCount; private LocalDateTime createdAt; public static CommentResponseDto fromEntity(Comment comment){ return CommentResponseDto.builder() - .id(comment.getComment_id()) + .id(comment.getCommentId()) .groupId(comment.getGroup().getId()) - .groupTitle(comment.getGroup().getTitle()) .userId(comment.getUser().getId()) .userName(comment.getUser().getName()) + .content(comment.getContent()) + .likeCount(comment.getLiked_by_users().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 index 4e8783d..b6c0102 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java +++ b/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java @@ -4,11 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -import java.util.Optional; public interface CommentRepository 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/comment/service/CommentService.java b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java index acddc44..4397b18 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -1,16 +1,19 @@ 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.CommentResponseDto; import com.example.lionsforest.domain.comment.repository.CommentRepository; import com.example.lionsforest.domain.group.Group; import com.example.lionsforest.domain.group.repository.GroupRepository; import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Set; @Service @RequiredArgsConstructor @@ -21,14 +24,13 @@ public class CommentService { // 댓글 생성 @Transactional - public CommentResponseDto createComment(Long groupId, Long userId){ + public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, User user){ Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); Comment comment = Comment.builder() .group(group) + .content(dto.getContent()) .user(user) .build(); @@ -38,11 +40,14 @@ public CommentResponseDto createComment(Long groupId, Long userId){ // 댓글 삭제 @Transactional - public void deleteComment(Long groupId, Long userId){ - Comment comment = commentRepository - .findByGroupIdAndUserId(groupId, userId) + public void deleteComment(Long commentId, User user){ + Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다.")); + if (!comment.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("댓글 작성자만 삭제할 수 있습니다."); + } + commentRepository.delete(comment); } @@ -55,6 +60,30 @@ public List getCommentsByGroupId(Long groupId){ .map(CommentResponseDto::fromEntity) .toList(); } + + // 댓글 좋아요 생성/취소 + @Transactional + public String toggleLike(Long commentId, User authUser){ + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다.")); + + // @ManyToMany의 주인(User) 엔티티를 가져와야 함 + User user = userRepository.findById(authUser.getId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + + // User 엔티티의 liked_comments Set을 가져옴 + Set likedComments = user.getLiked_comments(); + + if (likedComments.contains(comment)) { + // 이미 좋아요 누름 -> 좋아요 취소 + likedComments.remove(comment); + return "좋아요가 취소되었습니다."; + } else { + // 좋아요 안 누름 -> 좋아요 추가 + likedComments.add(comment); + return "좋아요가 추가되었습니다."; + } + } /* // 유저별 댓글 조회 @Transactional(readOnly = true) @@ -67,6 +96,4 @@ public List getCommentsByUserId(Long userId){ } */ - // 댓글 좋아요 생성/취소 - } diff --git a/src/main/java/com/example/lionsforest/domain/group/Group.java b/src/main/java/com/example/lionsforest/domain/group/Group.java index edd4052..14fe293 100644 --- a/src/main/java/com/example/lionsforest/domain/group/Group.java +++ b/src/main/java/com/example/lionsforest/domain/group/Group.java @@ -5,10 +5,7 @@ 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 lombok.*; import java.time.LocalDateTime; import java.util.ArrayList; @@ -16,6 +13,7 @@ @Entity @Getter +@Setter @Builder @AllArgsConstructor @NoArgsConstructor @@ -69,13 +67,4 @@ public class Group extends BaseTimeEntity { @OneToMany(mappedBy = "group", cascade = CascadeType.ALL) private List reviews = new ArrayList<>(); - public void update(String title, GroupCategory category, Integer capacity, LocalDateTime meetingAt, String location, GroupState state) { - this.title = title; - this.category = category; - this.capacity = capacity; - this.meetingAt = meetingAt; - this.location = location; - this.state = state; - } - } 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 index aaf4271..a572bd3 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -3,9 +3,15 @@ import com.example.lionsforest.domain.group.dto.request.GroupDeleteRequestDto; 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.GroupGetDetailResponseDto; +import com.example.lionsforest.domain.group.dto.response.GroupGetResponseDto; import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; import com.example.lionsforest.domain.group.service.GroupService; +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -18,40 +24,78 @@ public class GroupController { private final GroupService groupService; + private final UserRepository userRepository; // 모임 개설 - @PostMapping - public ResponseEntity createGroup(@RequestBody GroupRequestDto dto, - @RequestPart(value = "photos", required = false) List photos, - @PathVariable){ - User user = - return ResponseEntity.ok(groupService.createGroup(dto)); + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createGroup(@RequestPart("dto") GroupRequestDto dto, + @RequestPart(value = "photos", required = false) List photos){ + User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 + User user = userDetails.getUser(); // 진짜 인증(JWT) */ + + GroupResponseDto responseDto = groupService.createGroup(dto, photos, user); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); } -/* - // 모임 정보 전체 조회 + + // 모임 정보 전체 조회(썸네일 포함) @GetMapping - public ResponseEntity> getAllGroups(){ + public ResponseEntity> getAllGroups(){ return ResponseEntity.ok(groupService.getAllGroup()); } // 모임 정보 상세 조회 - @GetMapping("/{id}") - public ResponseEntity getGroupByID(@PathVariable Long id){ - return ResponseEntity.ok(groupService.getGroupById(id)); + @GetMapping("/{group_id}") + public ResponseEntity getGroupByID(@PathVariable Long groupId){ + GroupGetDetailResponseDto responseDto = groupService.getGroupById(groupId); + return ResponseEntity.ok(responseDto); } // 모임 정보 수정 - @PatchMapping("/{id}") - public ResponseEntity updateGroup(@PathVariable Long id, + @PatchMapping("/{group_id}") + public ResponseEntity updateGroup(@PathVariable Long groupId, @RequestBody GroupUpdateRequestDto dto){ - return ResponseEntity.ok(groupService.updateGroup(id, dto)); + User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 + User user = userDetails.getUser(); // 진짜 인증(JWT) */ + return ResponseEntity.ok(groupService.updateGroup(groupId, dto, user)); } // 모임 삭제 - @DeleteMapping("/{id}") - public ResponseEntity deleteGroup(@PathVariable Long id, + @DeleteMapping("/{group_id}") + public ResponseEntity deleteGroup(@PathVariable Long groupId, @RequestBody GroupDeleteRequestDto dto){ + User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 + User user = userDetails.getUser(); // 진짜 인증(JWT) */ + + groupService.deleteGroup(groupId, user); + return ResponseEntity.ok("모임이 성공적으로 삭제되었습니다."); } -*/ + + // 모임 사진 일괄 수정 (추가 + 삭제) + @PostMapping(value = "/{groupId}/photos/manage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity manageGroupPhotos( + @PathVariable Long groupId, + // 새로 추가할 파일 목록 + @RequestPart(value = "addPhotos", required = false) List addPhotos, + // 삭제할 사진 ID 목록 (예: ?deletePhotoIds=1&deletePhotoIds=3) + @RequestPart(value = "deletePhotoIds", required = false) List deletePhotoIds, + @RequestPart("userId") Long userId // [임시 인증] + ) { + // [임시 인증] + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 + User user = userDetails.getUser(); // 진짜 인증(JWT) */ + + // 서비스의 새 메서드 호출 + groupService.managePhotos(groupId, addPhotos, deletePhotoIds, user); + + return ResponseEntity.ok("사진이 성공적으로 수정되었습니다."); + } } 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 index 3f7506c..637148a 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java @@ -6,6 +6,7 @@ import com.example.lionsforest.domain.group.service.ParticipationService; import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -14,22 +15,33 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/participation") +@RequestMapping("/api/participation") public class ParticipationController { private final ParticipationService participationService; + private final UserRepository userRepository; // 모임 참여 @PostMapping("/{group_id}") public ResponseEntity joinGroup(@PathVariable Long groupId, @RequestBody ParticipationRequestDto dto){ - return ResponseEntity.ok(participationService.joinGroup(groupId, dto.getUserId())); + User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 + User user = userDetails.getUser(); // 진짜 인증(JWT) */ + + return ResponseEntity.ok(participationService.joinGroup(groupId, user)); } // 모임 탈퇴 @DeleteMapping("/{group_id}") public ResponseEntity leaveGroup(@PathVariable Long groupId, @RequestBody ParticipationRequestDto dto) { - participationService.leaveGroup(groupId, dto.getUserId()); + User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 + User user = userDetails.getUser(); // 진짜 인증(JWT) */ + + participationService.leaveGroup(groupId, user); return ResponseEntity.ok("모임에서 탈퇴했습니다."); } 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 index 6f22d98..02efe05 100644 --- 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 @@ -10,6 +10,7 @@ @Getter public class GroupRequestDto { + private Long userId; private String title; private GroupCategory category; private Integer capacity; 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 index 79fe113..6e9b351 100644 --- 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 @@ -11,8 +11,8 @@ public class GroupUpdateRequestDto { private Long userId; private String title; private GroupCategory category; - private Integer count; - private LocalDateTime meeting_at; + 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/GroupGetDetailResponseDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetDetailResponseDto.java new file mode 100644 index 0000000..ef3c40a --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetDetailResponseDto.java @@ -0,0 +1,45 @@ +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.GroupState; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +public class GroupGetDetailResponseDto { + private Long id; + private String title; + private GroupCategory category; + private Integer capacity; + private LocalDateTime meetingAt; + private String location; + private GroupState state; + + private List photos; // [추가] 전체 사진 목록 + + // Group 엔티티를 상세 DTO로 변환 + public static GroupGetDetailResponseDto fromEntity(Group group) { + + List photoDtos = group.getPhotos().stream() + .map(PhotoDto::new) // photo -> new PhotoDto(photo) + .collect(Collectors.toList()); + + return new GroupGetDetailResponseDto(group, photoDtos); + } + + // private 생성자 + private GroupGetDetailResponseDto(Group group, List photos) { + this.id = group.getId(); + this.title = group.getTitle(); + this.category = group.getCategory(); + this.capacity = group.getCapacity(); + this.meetingAt = group.getMeetingAt(); + this.location = group.getLocation(); + this.state = group.getState(); + this.photos = photos; // [추가] + } +} 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..a73f3f2 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetResponseDto.java @@ -0,0 +1,46 @@ +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; + +@Getter +@Builder +@AllArgsConstructor +public class GroupGetResponseDto { + private Long id; + private String title; + private GroupCategory category; + private Integer capacity; + private LocalDateTime meetingAt; + private String location; + private GroupState state; + + private String thumbnailUrl; + + public static GroupGetResponseDto fromEntity(Group group){ + + String thumbnailUrl = group.getPhotos().stream() + .min(Comparator.comparing(GroupPhoto::getPhoto_order)) // photo_order가 가장 낮은 (첫 번째) 사진 + .map(GroupPhoto::getPhoto) // 사진의 URL을 가져옴 + .orElse(null); // 사진이 없으면 null + + return GroupGetResponseDto.builder() + .id(group.getId()) + .title(group.getTitle()) + .category(group.getCategory()) + .capacity(group.getCapacity()) + .meetingAt(group.getMeetingAt()) + .location(group.getLocation()) + .state(group.getState()) + .thumbnailUrl(thumbnailUrl) + .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 index f928dab..f8c8b18 100644 --- 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 @@ -14,7 +14,6 @@ public class ParticipationResponseDto { private Long id; private Long groupId; - private String groupTitle; private Long userId; private String userName; private LocalDateTime createdAt; @@ -23,7 +22,6 @@ public static ParticipationResponseDto fromEntity(Participation participation){ return ParticipationResponseDto.builder() .id(participation.getId()) .groupId(participation.getGroup().getId()) - .groupTitle(participation.getGroup().getTitle()) .userId(participation.getUser().getId()) .userName(participation.getUser().getName()) .createdAt(participation.getCreatedAt()) diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/response/PhotoDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/PhotoDto.java new file mode 100644 index 0000000..e002c0a --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/PhotoDto.java @@ -0,0 +1,17 @@ +package com.example.lionsforest.domain.group.dto.response; + +import com.example.lionsforest.domain.group.GroupPhoto; +import lombok.Getter; + +@Getter +public class PhotoDto { + private Long photoId; + private String photoUrl; + private Integer order; + + public PhotoDto(GroupPhoto photo) { + this.photoId = photo.getId(); + this.photoUrl = photo.getPhoto(); + this.order = photo.getPhoto_order(); + } +} 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 index 472bd07..83d299f 100644 --- a/src/main/java/com/example/lionsforest/domain/group/repository/GroupPhotoRepository.java +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupPhotoRepository.java @@ -8,4 +8,5 @@ public interface GroupPhotoRepository extends JpaRepository { List findAllByGroup(Group group); + int countByGroupId(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 index 1b13035..523af51 100644 --- a/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java @@ -2,6 +2,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.example.lionsforest.domain.group.Group; - +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +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); } 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 index 31efc48..54089e6 100644 --- a/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java +++ b/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java @@ -12,10 +12,9 @@ public interface ParticipationRepository extends JpaRepository { boolean existsByGroupAndUser(Group group, User user); - //long countByGroupAndStatus(Group group, Participation); + long countByGroupId(Long groupId); Optional findByGroupIdAndUserId(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 index 72cf02a..6f53425 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -1,24 +1,25 @@ package com.example.lionsforest.domain.group.service; import com.example.lionsforest.domain.group.Group; -import com.example.lionsforest.domain.group.dto.request.GroupDeleteRequestDto; import com.example.lionsforest.domain.group.dto.request.GroupRequestDto; +import com.example.lionsforest.domain.group.dto.response.GroupGetDetailResponseDto; +import com.example.lionsforest.domain.group.dto.response.GroupGetResponseDto; import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; 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.user.User; -import com.example.lionsforest.domain.group.service.LocalUploadService; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -62,58 +63,69 @@ public GroupResponseDto createGroup(GroupRequestDto dto, saved.getLocation(), saved.getState()); } -/* // 모임 정보 전체 조회 - public List getAllGroup(){ + // 모임 정보 전체 조회(썸네일 포함) + @Transactional(readOnly = true) + public List getAllGroup(){ return groupRepository.findAll().stream() - .map(GroupResponseDto::fromEntity) - .toList(); + .map(GroupGetResponseDto::fromEntity) + .collect(Collectors.toList()); } // 모임 정보 상세 조회 - public GroupResponseDto getGroupById(Long id) { - Group product = groupRepository.findById(id) + @Transactional(readOnly = true) + public GroupGetDetailResponseDto getGroupById(Long id) { + + Group group = groupRepository.findByIdWithPhotos(id) .orElseThrow(() -> new IllegalArgumentException("해당 모임이 존재하지 않습니다.")); - return GroupResponseDto.fromEntity(product); + + return GroupGetDetailResponseDto.fromEntity(group); } // 모임 수정 @Transactional - public GroupResponseDto updateGroup(Long groupId, GroupUpdateRequestDto dto){ - - // 유저 조회 - User user = userRepository.findById(dto.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + public GroupResponseDto updateGroup(Long groupId, GroupUpdateRequestDto dto, User user){ // 모임 조회 Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); // 유저 권한 확인 - if(!group.getLeader().equals(user.getId())){ + if(!group.getLeader().getId().equals(user.getId())){ throw new IllegalArgumentException("모임장만 모임을 수정할 수 있습니다."); } - // 모임 정보 수정 - group.update(dto.getTitle(), dto.getCategory(), dto.getCount(), dto.getMeeting_at(), dto.getLocation(), dto.getState()); + 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, GroupDeleteRequestDto dto){ - - // 유저 조회 - User user = userRepository.findById(dto.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); - + public void deleteGroup(Long groupId, User user){ // 모임 조회 Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); // 유저 권한 확인 - if(!group.getLeader().equals(user.getId())){ + if(!group.getLeader().getId().equals(user.getId())){ throw new IllegalArgumentException("모임장만 모임을 수정할 수 있습니다."); } @@ -121,5 +133,57 @@ public void deleteGroup(Long groupId, GroupDeleteRequestDto dto){ groupRepository.delete(group); } -*/ + + //사진 일괄 관리 (추가 + 삭제) + @Transactional + public void managePhotos(Long groupId, List addPhotos, List deletePhotoIds, User user) { + + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + if (!group.getLeader().getId().equals(user.getId())) { + throw new IllegalArgumentException("모임장만 사진을 수정할 수 있습니다."); + } + + // 사진 갯수 제한 검증(최대 5장) + // 현재 사진 개수 + int currentPhotoCount = groupPhotoRepository.countByGroupId(groupId); // (Repository에 countByGroupId 추가 필요) + // 삭제할 개수 + int deleteCount = (deletePhotoIds != null) ? deletePhotoIds.size() : 0; + // 추가할 개수 + int addCount = (addPhotos != null) ? addPhotos.size() : 0; + // 최종 개수 확인 + if (currentPhotoCount - deleteCount + addCount > 5) { + throw new IllegalArgumentException("사진은 최대 5개까지만 업로드할 수 있습니다."); + } + + // 사진 삭제 (DB + 로컬/S3 파일 삭제) + if (deletePhotoIds != null && !deletePhotoIds.isEmpty()) { + List photosToDelete = groupPhotoRepository.findAllById(deletePhotoIds); + + for (GroupPhoto photo : photosToDelete) { + if (!photo.getGroup().getId().equals(groupId)) { + throw new IllegalArgumentException("다른 모임의 사진을 삭제할 수 없습니다."); + } + s3UploadService.delete(photo.getPhoto()); + } + groupPhotoRepository.deleteAll(photosToDelete); + } + + // 사진 추가 (로컬/S3 업로드 + DB 저장) + if (addPhotos != null && !addPhotos.isEmpty()) { + // (createGroup의 사진 추가 로직과 동일) + List groupPhotos = new ArrayList<>(); + // (참고: photo_order는 이 로직에서 관리하기 매우 복잡해집니다) + for (int i = 0; i < addPhotos.size(); i++) { + String photoUrl = s3UploadService.upload(addPhotos.get(i), "group-photos"); + groupPhotos.add(GroupPhoto.builder() + .group(group) + .photo(photoUrl) + .photo_order(i + 100) // (순서 로직은 별도 정책 필요) + .build()); + } + groupPhotoRepository.saveAll(groupPhotos); + } + } + } diff --git a/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java b/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java index 967208d..d695d21 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java @@ -47,4 +47,41 @@ public String upload(MultipartFile file, String dirName) { throw new RuntimeException("로컬 파일 업로드 중 오류가 발생했습니다.", e); } } + + /** + * [신규] 로컬 파일을 삭제하는 메서드 + * @param fileUrl DB에 저장된 웹 경로 (예: /uploads/group-photos/uuid.jpg) + */ + public void delete(String fileUrl) { + if (fileUrl == null || fileUrl.isBlank()) { + log.warn("삭제할 파일 URL이 비어있습니다."); + return; + } + + try { + // 1. 웹 URL을 실제 파일 시스템 경로로 변환 + // (예: /uploads/group-photos/uuid.jpg -> ./uploads/group-photos/uuid.jpg) + String filePath = fileUrl.replaceFirst("/uploads/", uploadDir); + + // 2. OS에 맞게 구분자(Separator) 변경 (예: / -> \) + filePath = filePath.replace("/", File.separator); + + File fileToDelete = new File(filePath); + + // 3. 파일이 존재하는지 확인 + if (fileToDelete.exists()) { + // 4. 파일 삭제 + if (fileToDelete.delete()) { + log.info("로컬 파일 삭제 성공: {}", filePath); + } else { + log.warn("로컬 파일 삭제 실패 (파일은 존재함): {}", filePath); + } + } else { + log.warn("삭제할 파일이 존재하지 않습니다: {}", filePath); + } + } catch (Exception e) { + // SecurityException 등 파일 접근 오류 + log.error("로컬 파일 삭제 중 오류가 발생했습니다: {}", fileUrl, e); + } + } } 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 index 755ceb5..47b6c84 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -19,28 +19,24 @@ public class ParticipationService { private final ParticipationRepository participationRepository; private final GroupRepository groupRepository; - private final UserRepository userRepository; // 모임 참여 @Transactional - public ParticipationResponseDto joinGroup(Long groupId, Long userId){ + public ParticipationResponseDto joinGroup(Long groupId, User user){ Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); // 중복 참여 체크 if(participationRepository.existsByGroupAndUser(group, user)) { throw new IllegalArgumentException("이미 참여 신청한 모임입니다."); } -/* + // 인원 제한 체크 - long currentCount = participationRepository.countByGroupAndStatus( - group, ParticipationStatus.APPROVED); - if(currentCount >= group.getCount()) { + long currentCount = participationRepository.countByGroupId(groupId); + if(currentCount >= group.getCapacity()) { throw new IllegalArgumentException("모임 인원이 가득 찼습니다."); } -*/ + Participation participation = Participation.builder() .group(group) .user(user) @@ -52,9 +48,9 @@ public ParticipationResponseDto joinGroup(Long groupId, Long userId){ // 모임 탈퇴 @Transactional - public void leaveGroup(Long groupId, Long userId) { + public void leaveGroup(Long groupId, User user) { Participation participation = participationRepository - .findByGroupIdAndUserId(groupId, userId) + .findByGroupIdAndUserId(groupId, user.getId()) .orElseThrow(() -> new IllegalArgumentException("참여하지 않은 모임입니다.")); participationRepository.delete(participation); 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 index 95880c8..ad892fa 100644 --- 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 @@ -4,5 +4,6 @@ @Getter public class ReviewRequestDto { - private Long userId; + private Integer score; + private String content; } From c0d410df732740c4bf35b98a0202808bee9c4be1 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 10 Nov 2025 13:07:41 +0900 Subject: [PATCH 12/78] =?UTF-8?q?feat:=20user=20api=20=EB=B0=8F=20jwt=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 42 ++++++++++++++ .../global/config/PrincipalHandler.java | 34 +++++++++++ .../global/config/SecurityConfig.java | 13 +++-- .../global/jwt/JwtAuthenticationFilter.java | 51 ++++++++++++++++ .../global/jwt/JwtTokenProvider.java | 58 ++++++++++++++----- 5 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/domain/user/controller/UserController.java create mode 100644 src/main/java/com/example/lionsforest/global/config/PrincipalHandler.java create mode 100644 src/main/java/com/example/lionsforest/global/jwt/JwtAuthenticationFilter.java 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..ce7cd97 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java @@ -0,0 +1,42 @@ +package com.example.lionsforest.domain.user.controller; + + +import com.example.lionsforest.domain.user.dto.UserInfoResponseDTO; +import com.example.lionsforest.domain.user.dto.UserUpdateRequestDTO; +import com.example.lionsforest.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + //유저 목록 조회 + @GetMapping + public ResponseEntity> getUserList() { + return ResponseEntity.ok(userService.getAllUsers()); + } + + //유저 상세 조회 + @GetMapping("/{userId}") + public ResponseEntity getUserDetail(@PathVariable Long userId) { + return ResponseEntity.ok(userService.getUserInfo(userId)); + } + + //유저 정보 수정 + @PatchMapping("/{userId}") + public ResponseEntity updateUser( + @PathVariable Long userId, + @RequestBody UserUpdateRequestDTO request) { + + + UserInfoResponseDTO updatedUser = userService.updateUserInfo(userId, request); + return ResponseEntity.ok(updatedUser); + } +} \ No newline at end of file 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/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java index d59eec6..d439eab 100644 --- a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -1,6 +1,8 @@ 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; @@ -8,13 +10,14 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwtTokenProvider) throws Exception { http // 1. CSRF 비활성화 .csrf(AbstractHttpConfigurer::disable) @@ -35,10 +38,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth // "/auth/**" 경로는 인증 없이 무조건 통과(permitAll) .requestMatchers("/auth/**").permitAll() - - // 추후 "/api/**" 등 다른 엔드포인트는 인증(JWT)이 필요하도록 설정 + //api 경로: 인증 되면 통과 + .requestMatchers("/api/**").authenticated() .anyRequest().authenticated() - ); + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class); // 추후 JWT 필터 추가 필요 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 index 6e82d8d..43569f9 100644 --- a/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java +++ b/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java @@ -4,14 +4,21 @@ import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import lombok.AllArgsConstructor; -import lombok.Getter; 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 @@ -35,8 +42,8 @@ public JwtTokenProvider( //액세스 토큰 & 리프레시 토큰 생성 public TokenResponseDTO createTokens(Long userId, String email) { - String accessToken = generateToken(userId, email, accessTokenValidityInMs); - String refreshToken = generateToken(userId, email, refreshTokenValidityInMs); // Refresh Token에도 기본 정보 포함 + String accessToken = generateToken(userId, email, accessTokenValidityInMs, "Access"); + String refreshToken = generateToken(userId, email, refreshTokenValidityInMs, "Refresh"); // Refresh Token에도 기본 정보 포함 return TokenResponseDTO.builder() .grantType("Bearer") @@ -46,17 +53,20 @@ public TokenResponseDTO createTokens(Long userId, String email) { } // 실제 토큰을 생성하는 메서드 - private String generateToken(Long userId, String email, long validityMs) { + private String generateToken(Long userId, String email, long validityMs, String tokenType) { Date now = new Date(); Date validity = new Date(now.getTime() + validityMs); - return Jwts.builder() - .setSubject(String.valueOf(userId)) // 토큰의 주체 (유저 ID) - .claim("email", email) // 커스텀 클레임 (이메일) - .setIssuedAt(now) // 발급 시간 - .setExpiration(validity) // 만료 시간 - .signWith(key, SignatureAlgorithm.HS256) // 서명 - .compact(); + 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(); } // 토큰에서 유저 아이디 추출 @@ -82,7 +92,7 @@ public boolean validateToken(String token) { return false; } - //토큰에서 정보 파싱 + //토큰에서 정보 파싱 - 추출 private Claims parseClaims(String token) { try { return Jwts.parserBuilder() @@ -95,4 +105,26 @@ private Claims parseClaims(String token) { 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); + } } From 542fe9f48d5c14c199493b2a15c53cd15d7e6420 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 10 Nov 2025 19:20:54 +0900 Subject: [PATCH 13/78] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/domain/comment/service/CommentService.java | 1 + .../domain/group/service/ParticipationService.java | 6 ++++-- .../domain/review/controller/ReviewController.java | 2 +- .../lionsforest/domain/review/service/ReviewService.java | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) 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 index acddc44..04bdaaa 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -6,6 +6,7 @@ import com.example.lionsforest.domain.group.Group; import com.example.lionsforest.domain.group.repository.GroupRepository; import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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 index 5492dab..cdfdafb 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -7,6 +7,7 @@ import com.example.lionsforest.domain.group.Participation; import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; +import com.example.lionsforest.domain.user.repository.UserRepository; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -34,11 +35,12 @@ public ParticipationResponseDto joinGroup(Long groupId, Long userId){ } // 인원 제한 체크 - long currentCount = participationRepository.countByGroupAndStatus( + // ****EC2 배포 과정에서 오류가 떠서 일단 주석 처리합니다**** + /* long currentCount = participationRepository.countByGroupAndStatus(( group, ParticipationStatus.APPROVED); if(currentCount >= group.getCount()) { throw new IllegalArgumentException("모임 인원이 가득 찼습니다."); - } + }*/ Participation participation = Participation.builder() .group(group) 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 index c93d589..0f0fb46 100644 --- a/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java +++ b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java @@ -27,7 +27,7 @@ public ResponseEntity create(@PathVariable Long groupId, public ResponseEntity deleteReview(@PathVariable Long groupId, @RequestBody ReviewRequestDto dto){ reviewService.deleteReview(groupId, dto.getUserId()); - return ResponseEntity.ok("후기가 삭제 되었습니다.") + return ResponseEntity.ok("후기가 삭제 되었습니다."); } // 모임별 후기 조회 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 index bd0bed1..8202539 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -8,6 +8,7 @@ import com.example.lionsforest.domain.review.dto.response.ReviewResponseDto; import com.example.lionsforest.domain.review.repository.ReviewRepository; import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +35,7 @@ public ReviewResponseDto createReview(Long groupId, Long userId){ .user(user) .build(); - Review saved = ReviewRepository.save(review); + Review saved = reviewRepository.save(review); return ReviewResponseDto.fromEntity(saved); } From 280fb51efa5569e70d8d4660b4f4392ecf7b1364 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 10 Nov 2025 21:10:16 +0900 Subject: [PATCH 14/78] =?UTF-8?q?chore:=20ec2=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ build.gradle | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index c123ce7..51cb6ef 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,6 @@ out/ #Spring 설정 파일 application.yml src/main/resources/application-secret.yml +src/main/resources/application-deployment.yml +src/main/resources/application-development.yml src/main/resources/members.txt \ No newline at end of file diff --git a/build.gradle b/build.gradle index 010d1ab..b5a59e2 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,8 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // @Valid 어노테이션을 위한 의존성 implementation 'org.springframework.boot:spring-boot-starter-validation' + // mysql jdbc 드라이버 의존성 + runtimeOnly 'com.mysql:mysql-connector-j' } tasks.named('test') { From 1ebe963b79ff90067da263f62ca0e53d8e877cb5 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 10 Nov 2025 21:15:02 +0900 Subject: [PATCH 15/78] =?UTF-8?q?chore:=20gitignore=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 -- src/main/resources/application-deployment.yml | 6 ++++++ src/main/resources/application-development.yml | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/application-deployment.yml create mode 100644 src/main/resources/application-development.yml diff --git a/.gitignore b/.gitignore index 51cb6ef..c123ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,4 @@ out/ #Spring 설정 파일 application.yml src/main/resources/application-secret.yml -src/main/resources/application-deployment.yml -src/main/resources/application-development.yml src/main/resources/members.txt \ No newline at end of file diff --git a/src/main/resources/application-deployment.yml b/src/main/resources/application-deployment.yml new file mode 100644 index 0000000..4a36a24 --- /dev/null +++ b/src/main/resources/application-deployment.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: jdbc:mysql://${database.deployment.host}/${database.name} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${database.username} + password: ${database.password} \ 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..ddd39d8 --- /dev/null +++ b/src/main/resources/application-development.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: jdbc:mysql://${database.development.host}/${database.name} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${database.username} + password: ${database.password} \ No newline at end of file From ed87fc8ea4801095894f5740180e2055bdc2c326 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 11 Nov 2025 02:46:09 +0900 Subject: [PATCH 16/78] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84,=20=ED=9B=84?= =?UTF-8?q?=EA=B8=B0=20=EC=82=AC=EC=A7=84=20=EA=B4=80=EB=A0=A8=20api=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20s3=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../comment/controller/CommentController.java | 35 ++-- .../dto/request/CommentRequestDto.java | 1 - .../comment/service/CommentService.java | 14 +- .../group/controller/GroupController.java | 49 +++--- .../controller/ParticipationController.java | 31 ++-- .../dto/request/GroupDeleteRequestDto.java | 8 - .../group/dto/request/GroupRequestDto.java | 1 - .../dto/request/GroupUpdateRequestDto.java | 1 - .../dto/request/ParticipationRequestDto.java | 8 - .../response/GroupGetDetailResponseDto.java | 45 ------ .../dto/response/GroupGetResponseDto.java | 13 +- .../{PhotoDto.java => GroupPhotoDto.java} | 11 +- .../response/ParticipationResponseDto.java | 1 - .../domain/group/service/GroupService.java | 43 +++-- .../group/service/ParticipationService.java | 12 +- .../lionsforest/domain/review/Review.java | 6 +- .../domain/review/ReviewPhoto.java | 6 +- .../review/controller/ReviewController.java | 59 +++++-- .../dto/request/ReviewUpdateRequestDto.java | 12 ++ .../dto/response/ReviewGetResponseDto.java | 43 +++++ .../review/dto/response/ReviewPhotoDto.java | 15 ++ .../dto/response/ReviewResponseDto.java | 4 - .../repository/ReviewPhotoRepository.java | 12 ++ .../domain/review/service/ReviewService.java | 152 ++++++++++++++++-- .../global/common/S3UploadService.java | 106 ++++++++++++ .../lionsforest/global/config/S3Config.java | 31 ++++ 27 files changed, 523 insertions(+), 198 deletions(-) delete mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/request/GroupDeleteRequestDto.java delete mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/request/ParticipationRequestDto.java delete mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetDetailResponseDto.java rename src/main/java/com/example/lionsforest/domain/group/dto/response/{PhotoDto.java => GroupPhotoDto.java} (56%) create mode 100644 src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewUpdateRequestDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewGetResponseDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewPhotoDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/review/repository/ReviewPhotoRepository.java create mode 100644 src/main/java/com/example/lionsforest/global/common/S3UploadService.java create mode 100644 src/main/java/com/example/lionsforest/global/config/S3Config.java diff --git a/build.gradle b/build.gradle index 010d1ab..65f6f92 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,8 @@ dependencies { 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' } tasks.named('test') { 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 index e317f49..04947a2 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java +++ b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java @@ -3,12 +3,11 @@ import com.example.lionsforest.domain.comment.dto.request.CommentRequestDto; import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; import com.example.lionsforest.domain.comment.service.CommentService; -import com.example.lionsforest.domain.group.dto.request.ParticipationRequestDto; -import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; import com.example.lionsforest.domain.user.User; import com.example.lionsforest.domain.user.repository.UserRepository; 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; @@ -19,46 +18,42 @@ @RequestMapping("/api/comments") public class CommentController { private final CommentService commentService; - private final UserRepository userRepository; // 댓글 생성 @PostMapping("/{group_id}") - public ResponseEntity createComment(@PathVariable Long groupId - ,@RequestBody CommentRequestDto dto){ - User user = userRepository.findById(dto.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + 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, user)); + return ResponseEntity.ok(commentService.createComment(groupId, dto, loginUserId)); } // 댓글 삭제 @DeleteMapping("/{comment_id}") - public ResponseEntity deleteComment(@PathVariable Long commentId, - @RequestBody CommentRequestDto dto) { - User user = userRepository.findById(dto.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + 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, user); + commentService.deleteComment(commentId, loginUserId); return ResponseEntity.ok("댓글이 삭제 되었습니다."); } // 모임별 댓글 조회 @GetMapping("/{group_id}") - public ResponseEntity> getCommentByGroup(@PathVariable Long groupId){ + public ResponseEntity> getCommentByGroup(@PathVariable("group_id") Long groupId){ return ResponseEntity.ok(commentService.getCommentsByGroupId(groupId)); } // 특정 댓글 좋아요 (Toggle) @PostMapping("/{comment_id}/like") public ResponseEntity toggleLike( - @PathVariable Long commentId, - @RequestBody Map requestBody + @PathVariable("comment_id") Long commentId, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal ) { - Long userId = requestBody.get("userId"); - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + Long loginUserId = Long.valueOf(principal.getUsername()); - String message = commentService.toggleLike(commentId, user); + String message = commentService.toggleLike(commentId, loginUserId); return ResponseEntity.ok(message); } /* 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 index 09fda86..e5b9577 100644 --- 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 @@ -7,5 +7,4 @@ public class CommentRequestDto { @NotBlank(message = "댓글 내용을 입력해주세요.") private String content; - private 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 index 4397b18..fb8efcc 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -24,10 +24,13 @@ public class CommentService { // 댓글 생성 @Transactional - public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, User user){ + public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, Long userId){ Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + Comment comment = Comment.builder() .group(group) .content(dto.getContent()) @@ -40,10 +43,13 @@ public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, Use // 댓글 삭제 @Transactional - public void deleteComment(Long commentId, User user){ + public void deleteComment(Long commentId, Long userId){ Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다.")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + if (!comment.getUser().getId().equals(user.getId())) { throw new IllegalArgumentException("댓글 작성자만 삭제할 수 있습니다."); } @@ -63,12 +69,12 @@ public List getCommentsByGroupId(Long groupId){ // 댓글 좋아요 생성/취소 @Transactional - public String toggleLike(Long commentId, User authUser){ + public String toggleLike(Long commentId, Long userId){ Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다.")); // @ManyToMany의 주인(User) 엔티티를 가져와야 함 - User user = userRepository.findById(authUser.getId()) + User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); // User 엔티티의 liked_comments Set을 가져옴 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 index a572bd3..7e5323d 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -1,9 +1,7 @@ package com.example.lionsforest.domain.group.controller; -import com.example.lionsforest.domain.group.dto.request.GroupDeleteRequestDto; 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.GroupGetDetailResponseDto; import com.example.lionsforest.domain.group.dto.response.GroupGetResponseDto; import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; import com.example.lionsforest.domain.group.service.GroupService; @@ -13,6 +11,7 @@ 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 org.springframework.web.multipart.MultipartFile; @@ -24,22 +23,19 @@ public class GroupController { private final GroupService groupService; - private final UserRepository userRepository; // 모임 개설 @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createGroup(@RequestPart("dto") GroupRequestDto dto, - @RequestPart(value = "photos", required = false) List photos){ - User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); - /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 - User user = userDetails.getUser(); // 진짜 인증(JWT) */ + @RequestPart(value = "photos", required = false) List photos, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); - GroupResponseDto responseDto = groupService.createGroup(dto, photos, user); + GroupResponseDto responseDto = groupService.createGroup(dto, photos, loginUserId); return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); } - // 모임 정보 전체 조회(썸네일 포함) + // 모임 정보 전체 조회 @GetMapping public ResponseEntity> getAllGroups(){ return ResponseEntity.ok(groupService.getAllGroup()); @@ -47,36 +43,32 @@ public ResponseEntity> getAllGroups(){ // 모임 정보 상세 조회 @GetMapping("/{group_id}") - public ResponseEntity getGroupByID(@PathVariable Long groupId){ - GroupGetDetailResponseDto responseDto = groupService.getGroupById(groupId); + public ResponseEntity getGroupByID(@PathVariable("group_id") Long groupId){ + GroupGetResponseDto responseDto = groupService.getGroupById(groupId); return ResponseEntity.ok(responseDto); } // 모임 정보 수정 @PatchMapping("/{group_id}") - public ResponseEntity updateGroup(@PathVariable Long groupId, - @RequestBody GroupUpdateRequestDto dto){ - User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); - /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 - User user = userDetails.getUser(); // 진짜 인증(JWT) */ - return ResponseEntity.ok(groupService.updateGroup(groupId, dto, user)); + 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}") - public ResponseEntity deleteGroup(@PathVariable Long groupId, - @RequestBody GroupDeleteRequestDto dto){ - User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); - /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 - User user = userDetails.getUser(); // 진짜 인증(JWT) */ + 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, user); + groupService.deleteGroup(groupId, loginUserId); return ResponseEntity.ok("모임이 성공적으로 삭제되었습니다."); } - + /* // 모임 사진 일괄 수정 (추가 + 삭제) @PostMapping(value = "/{groupId}/photos/manage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity manageGroupPhotos( @@ -91,11 +83,12 @@ public ResponseEntity manageGroupPhotos( User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 - User user = userDetails.getUser(); // 진짜 인증(JWT) */ + User user = userDetails.getUser(); // 진짜 인증(JWT) // 서비스의 새 메서드 호출 groupService.managePhotos(groupId, addPhotos, deletePhotoIds, user); return ResponseEntity.ok("사진이 성공적으로 수정되었습니다."); } + */ } 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 index 637148a..c777ea0 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java @@ -1,14 +1,13 @@ package com.example.lionsforest.domain.group.controller; -import com.example.lionsforest.domain.group.dto.request.ParticipationRequestDto; import com.example.lionsforest.domain.group.dto.response.ParticipationResponseDto; -import com.example.lionsforest.domain.group.repository.ParticipationRepository; import com.example.lionsforest.domain.group.service.ParticipationService; import com.example.lionsforest.domain.user.User; import com.example.lionsforest.domain.user.repository.UserRepository; 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; @@ -22,32 +21,26 @@ public class ParticipationController { // 모임 참여 @PostMapping("/{group_id}") - public ResponseEntity joinGroup(@PathVariable Long groupId, - @RequestBody ParticipationRequestDto dto){ - User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); - /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 - User user = userDetails.getUser(); // 진짜 인증(JWT) */ - - return ResponseEntity.ok(participationService.joinGroup(groupId, user)); + 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)); } // 모임 탈퇴 @DeleteMapping("/{group_id}") - public ResponseEntity leaveGroup(@PathVariable Long groupId, - @RequestBody ParticipationRequestDto dto) { - User user = userRepository.findById(dto.getUserId()) // 임시 인증(JWT 이전) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); - /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 - User user = userDetails.getUser(); // 진짜 인증(JWT) */ - - participationService.leaveGroup(groupId, user); + 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}") - public ResponseEntity> getUser(@PathVariable Long groupId){ + 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/GroupDeleteRequestDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupDeleteRequestDto.java deleted file mode 100644 index 4749cfb..0000000 --- a/src/main/java/com/example/lionsforest/domain/group/dto/request/GroupDeleteRequestDto.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.lionsforest.domain.group.dto.request; - -import lombok.Getter; - -@Getter -public class GroupDeleteRequestDto { - private Long userId; -} 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 index 02efe05..6f22d98 100644 --- 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 @@ -10,7 +10,6 @@ @Getter public class GroupRequestDto { - private Long userId; private String title; private GroupCategory category; private Integer capacity; 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 index 6e9b351..402d4bf 100644 --- 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 @@ -8,7 +8,6 @@ @Getter public class GroupUpdateRequestDto { - private Long userId; private String title; private GroupCategory category; private Integer capacity; diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/request/ParticipationRequestDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/request/ParticipationRequestDto.java deleted file mode 100644 index b7c5392..0000000 --- a/src/main/java/com/example/lionsforest/domain/group/dto/request/ParticipationRequestDto.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.lionsforest.domain.group.dto.request; - -import lombok.Getter; - -@Getter -public class ParticipationRequestDto { - private Long userId; -} diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetDetailResponseDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetDetailResponseDto.java deleted file mode 100644 index ef3c40a..0000000 --- a/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupGetDetailResponseDto.java +++ /dev/null @@ -1,45 +0,0 @@ -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.GroupState; -import lombok.Getter; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Collectors; - -@Getter -public class GroupGetDetailResponseDto { - private Long id; - private String title; - private GroupCategory category; - private Integer capacity; - private LocalDateTime meetingAt; - private String location; - private GroupState state; - - private List photos; // [추가] 전체 사진 목록 - - // Group 엔티티를 상세 DTO로 변환 - public static GroupGetDetailResponseDto fromEntity(Group group) { - - List photoDtos = group.getPhotos().stream() - .map(PhotoDto::new) // photo -> new PhotoDto(photo) - .collect(Collectors.toList()); - - return new GroupGetDetailResponseDto(group, photoDtos); - } - - // private 생성자 - private GroupGetDetailResponseDto(Group group, List photos) { - this.id = group.getId(); - this.title = group.getTitle(); - this.category = group.getCategory(); - this.capacity = group.getCapacity(); - this.meetingAt = group.getMeetingAt(); - this.location = group.getLocation(); - this.state = group.getState(); - this.photos = photos; // [추가] - } -} 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 index a73f3f2..8560a53 100644 --- 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 @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.Comparator; +import java.util.List; @Getter @Builder @@ -23,14 +24,14 @@ public class GroupGetResponseDto { private String location; private GroupState state; - private String thumbnailUrl; + private List photos; public static GroupGetResponseDto fromEntity(Group group){ - String thumbnailUrl = group.getPhotos().stream() - .min(Comparator.comparing(GroupPhoto::getPhoto_order)) // photo_order가 가장 낮은 (첫 번째) 사진 - .map(GroupPhoto::getPhoto) // 사진의 URL을 가져옴 - .orElse(null); // 사진이 없으면 null + List photos = group.getPhotos().stream() + .sorted(Comparator.comparing(GroupPhoto::getPhoto_order)) + .map(GroupPhotoDto::new) + .toList(); return GroupGetResponseDto.builder() .id(group.getId()) @@ -40,7 +41,7 @@ public static GroupGetResponseDto fromEntity(Group group){ .meetingAt(group.getMeetingAt()) .location(group.getLocation()) .state(group.getState()) - .thumbnailUrl(thumbnailUrl) + .photos(photos) .build(); } } diff --git a/src/main/java/com/example/lionsforest/domain/group/dto/response/PhotoDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupPhotoDto.java similarity index 56% rename from src/main/java/com/example/lionsforest/domain/group/dto/response/PhotoDto.java rename to src/main/java/com/example/lionsforest/domain/group/dto/response/GroupPhotoDto.java index e002c0a..ee20227 100644 --- a/src/main/java/com/example/lionsforest/domain/group/dto/response/PhotoDto.java +++ b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupPhotoDto.java @@ -1,16 +1,15 @@ package com.example.lionsforest.domain.group.dto.response; import com.example.lionsforest.domain.group.GroupPhoto; + import lombok.Getter; @Getter -public class PhotoDto { - private Long photoId; - private String photoUrl; - private Integer order; +public class GroupPhotoDto { + private final String photoUrl; + private final Integer order; - public PhotoDto(GroupPhoto photo) { - this.photoId = photo.getId(); + public GroupPhotoDto(GroupPhoto photo) { this.photoUrl = photo.getPhoto(); this.order = photo.getPhoto_order(); } 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 index f8c8b18..20dbbb4 100644 --- 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 @@ -1,7 +1,6 @@ package com.example.lionsforest.domain.group.dto.response; import com.example.lionsforest.domain.group.Participation; -import com.example.lionsforest.domain.group.dto.request.ParticipationRequestDto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; 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 index 6f53425..01c7422 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -2,7 +2,6 @@ import com.example.lionsforest.domain.group.Group; import com.example.lionsforest.domain.group.dto.request.GroupRequestDto; -import com.example.lionsforest.domain.group.dto.response.GroupGetDetailResponseDto; import com.example.lionsforest.domain.group.dto.response.GroupGetResponseDto; import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; import com.example.lionsforest.domain.group.repository.GroupRepository; @@ -12,6 +11,8 @@ import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.common.S3UploadService; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -26,13 +27,17 @@ public class GroupService { private final GroupRepository groupRepository; private final GroupPhotoRepository groupPhotoRepository; - private final LocalUploadService s3UploadService; // 파일업로드(지금은 우선 로컬로) + private final UserRepository userRepository; + private final S3UploadService s3UploadService; // 모임 개설 @Transactional public GroupResponseDto createGroup(GroupRequestDto dto, List photos, - User user){ + Long userId){ + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + // Group Entity 먼저 생성(ID 확보) Group group = dto.toEntity(user); Group saved = groupRepository.save(group); @@ -42,8 +47,8 @@ public GroupResponseDto createGroup(GroupRequestDto dto, for (int i = 0; i < photos.size(); i++) { MultipartFile photo = photos.get(i); - // S3(또는 로컬)에 파일 업로드 -> URL 반환 - String photoUrl = s3UploadService.upload(photo, "s3폴더경로"); + // S3에 파일 업로드 -> URL 반환 + String photoUrl = s3UploadService.upload(photo, "group-photos"); // GroupPhoto 엔티티 생성 GroupPhoto groupPhoto = GroupPhoto.builder() .group(saved) // 저장된 Group 객체 @@ -63,7 +68,7 @@ public GroupResponseDto createGroup(GroupRequestDto dto, saved.getLocation(), saved.getState()); } - // 모임 정보 전체 조회(썸네일 포함) + // 모임 정보 전체 조회 @Transactional(readOnly = true) public List getAllGroup(){ return groupRepository.findAll().stream() @@ -74,22 +79,26 @@ public List getAllGroup(){ // 모임 정보 상세 조회 @Transactional(readOnly = true) - public GroupGetDetailResponseDto getGroupById(Long id) { + public GroupGetResponseDto getGroupById(Long groupId) { - Group group = groupRepository.findByIdWithPhotos(id) + Group group = groupRepository.findByIdWithPhotos(groupId) .orElseThrow(() -> new IllegalArgumentException("해당 모임이 존재하지 않습니다.")); - return GroupGetDetailResponseDto.fromEntity(group); + return GroupGetResponseDto.fromEntity(group); } // 모임 수정 @Transactional - public GroupResponseDto updateGroup(Long groupId, GroupUpdateRequestDto dto, User user){ + public GroupResponseDto updateGroup(Long groupId, GroupUpdateRequestDto dto, Long userId){ // 모임 조회 Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + // 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + // 유저 권한 확인 if(!group.getLeader().getId().equals(user.getId())){ throw new IllegalArgumentException("모임장만 모임을 수정할 수 있습니다."); @@ -119,21 +128,30 @@ public GroupResponseDto updateGroup(Long groupId, GroupUpdateRequestDto dto, Use // 모임 삭제 @Transactional - public void deleteGroup(Long groupId, User user){ + public void deleteGroup(Long groupId, Long userId){ // 모임 조회 Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + // 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + // 유저 권한 확인 if(!group.getLeader().getId().equals(user.getId())){ throw new IllegalArgumentException("모임장만 모임을 수정할 수 있습니다."); } + // 사진 삭제 + if (group.getPhotos() != null) { + group.getPhotos().forEach(p -> s3UploadService.delete(p.getPhoto())); + } + //삭제 groupRepository.delete(group); } - + /* //사진 일괄 관리 (추가 + 삭제) @Transactional public void managePhotos(Long groupId, List addPhotos, List deletePhotoIds, User user) { @@ -185,5 +203,6 @@ public void managePhotos(Long groupId, List addPhotos, List groupPhotoRepository.saveAll(groupPhotos); } } + */ } 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 index 47b6c84..48898d7 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -19,13 +19,17 @@ public class ParticipationService { private final ParticipationRepository participationRepository; private final GroupRepository groupRepository; + private final UserRepository userRepository; // 모임 참여 @Transactional - public ParticipationResponseDto joinGroup(Long groupId, User user){ + public ParticipationResponseDto joinGroup(Long groupId, Long userId){ Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + // 중복 참여 체크 if(participationRepository.existsByGroupAndUser(group, user)) { throw new IllegalArgumentException("이미 참여 신청한 모임입니다."); @@ -48,7 +52,11 @@ public ParticipationResponseDto joinGroup(Long groupId, User user){ // 모임 탈퇴 @Transactional - public void leaveGroup(Long groupId, User user) { + public void leaveGroup(Long groupId, Long userId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + Participation participation = participationRepository .findByGroupIdAndUserId(groupId, user.getId()) .orElseThrow(() -> new IllegalArgumentException("참여하지 않은 모임입니다.")); diff --git a/src/main/java/com/example/lionsforest/domain/review/Review.java b/src/main/java/com/example/lionsforest/domain/review/Review.java index f5663a2..a2193b8 100644 --- a/src/main/java/com/example/lionsforest/domain/review/Review.java +++ b/src/main/java/com/example/lionsforest/domain/review/Review.java @@ -4,16 +4,14 @@ 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 lombok.*; import java.util.ArrayList; import java.util.List; @Entity @Getter +@Setter @Builder @AllArgsConstructor @NoArgsConstructor diff --git a/src/main/java/com/example/lionsforest/domain/review/ReviewPhoto.java b/src/main/java/com/example/lionsforest/domain/review/ReviewPhoto.java index a520a55..ac41c24 100644 --- a/src/main/java/com/example/lionsforest/domain/review/ReviewPhoto.java +++ b/src/main/java/com/example/lionsforest/domain/review/ReviewPhoto.java @@ -1,13 +1,11 @@ package com.example.lionsforest.domain.review; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Getter +@Setter @Builder @AllArgsConstructor @NoArgsConstructor 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 index 0f0fb46..c84881b 100644 --- a/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java +++ b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java @@ -1,44 +1,77 @@ 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 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("/reviews") +@RequestMapping("/api/reviews") public class ReviewController { private final ReviewService reviewService; // 후기 생성 - @PostMapping("/{group_id}") - public ResponseEntity create(@PathVariable Long groupId, - @RequestBody ReviewRequestDto dto){ - return ResponseEntity.ok(reviewService.createReview(groupId, dto.getUserId())); + @PostMapping(value = "/{group_id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createReview(@PathVariable("group_id") Long groupId, + @RequestPart("dto") ReviewRequestDto dto, + @RequestPart(value = "photos", required = false) List photos, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + return ResponseEntity.ok(reviewService.createReview(groupId, dto, photos, loginUserId)); } - // 후기 삭제 - @DeleteMapping("/{group_id}") - public ResponseEntity deleteReview(@PathVariable Long groupId, - @RequestBody ReviewRequestDto dto){ - reviewService.deleteReview(groupId, dto.getUserId()); - return ResponseEntity.ok("후기가 삭제 되었습니다."); + // 후기 수정 + @PatchMapping(value = "/{review_id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateReview(@PathVariable("review_id") Long reviewId, + @RequestPart("dto") ReviewUpdateRequestDto dto, + @RequestPart(value = "addPhotos", required = false) List addPhotos, + @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + Long loginUserId = Long.valueOf(principal.getUsername()); + + return ResponseEntity.ok(reviewService.updateReview(reviewId, dto, addPhotos, loginUserId)); + } + + // 개별 후기 조회 + @GetMapping("/{review_id}") + public ResponseEntity getReviewById(@PathVariable("review_id") Long reviewId){ + return ResponseEntity.ok(reviewService.getReviewById(reviewId)); } // 모임별 후기 조회 @GetMapping("/{group_id}") - public ResponseEntity> getReviewByGroupId(@PathVariable Long groupId){ + public ResponseEntity> getReviewByGroupId(@PathVariable("group_id") Long groupId){ return ResponseEntity.ok(reviewService.getReviewByGroupId(groupId)); } // 특정 유저의 후기 전체 조회 @GetMapping("/{user_id}") - public ResponseEntity> getReviewByUserId(@PathVariable Long userId){ + public ResponseEntity> getReviewByUserId(@PathVariable("group_id") Long userId){ return ResponseEntity.ok(reviewService.getReviewByUserId(userId)); } + + // 후기 삭제 + @DeleteMapping("/{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/ReviewUpdateRequestDto.java b/src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewUpdateRequestDto.java new file mode 100644 index 0000000..79fc3c6 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/request/ReviewUpdateRequestDto.java @@ -0,0 +1,12 @@ +package com.example.lionsforest.domain.review.dto.request; + +import lombok.Getter; + +import java.util.List; + +@Getter +public class ReviewUpdateRequestDto { + private Integer score; + private String content; + private List deletePhotoIds; +} 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..7dd7f67 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewGetResponseDto.java @@ -0,0 +1,43 @@ +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 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.getReview_id()) + .groupId(review.getGroup().getId()) + .userId(review.getUser().getId()) + .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..b462714 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewPhotoDto.java @@ -0,0 +1,15 @@ +package com.example.lionsforest.domain.review.dto.response; + +import com.example.lionsforest.domain.review.ReviewPhoto; +import lombok.Getter; + +@Getter +public class ReviewPhotoDto { + private final String photoUrl; + private final Integer order; + + public ReviewPhotoDto(ReviewPhoto photo) { + 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 index 52abc8f..e85854b 100644 --- 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 @@ -15,9 +15,7 @@ public class ReviewResponseDto { private Long id; private Long groupId; - private String groupTitle; private Long userId; - private String userName; private String content; private Integer score; private LocalDateTime createdAt; @@ -26,9 +24,7 @@ public static ReviewResponseDto fromEntity(Review review){ return ReviewResponseDto.builder() .id(review.getReview_id()) .groupId(review.getGroup().getId()) - .groupTitle(review.getGroup().getTitle()) .userId(review.getUser().getId()) - .userName(review.getUser().getName()) .content(review.getContent()) .score(review.getScore()) .createdAt(review.getCreatedAt()) 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/service/ReviewService.java b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java index de5460a..a18ab6d 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -2,26 +2,41 @@ import com.example.lionsforest.domain.group.Group; import com.example.lionsforest.domain.group.repository.GroupRepository; +import com.example.lionsforest.domain.group.service.LocalUploadService; 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 lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.util.ArrayList; import java.util.List; @Service @RequiredArgsConstructor public class ReviewService { private final ReviewRepository reviewRepository; + private final ReviewPhotoRepository reviewPhotoRepository; private final GroupRepository groupRepository; private final UserRepository userRepository; + private final S3UploadService s3UploadService; // 후기 생성 @Transactional - public ReviewResponseDto createReview(Long groupId, Long userId){ + public ReviewResponseDto createReview(Long groupId, + ReviewRequestDto dto, + List photos, + Long userId){ Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); User user = userRepository.findById(userId) @@ -29,52 +44,167 @@ public ReviewResponseDto createReview(Long groupId, Long userId){ Review review = Review.builder() .group(group) + .score(dto.getScore()) + .content(dto.getContent()) .user(user) .build(); Review saved = reviewRepository.save(review); + + 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); + } return ReviewResponseDto.fromEntity(saved); } // 후기 삭제 @Transactional - public void deleteReview(Long groupId, Long userId){ + public void deleteReview(Long reviewId, Long userId){ + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + Review review = reviewRepository - .findByGroupIdAndUserId(groupId, userId) + .findById(reviewId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 후기입니다.")); + if(!review.getUser().getId().equals(userId)) { + throw new IllegalArgumentException("작성자만 후기를 삭제할 수 있습니다."); + } + + if (review.getPhotos() != null) { + review.getPhotos().forEach(p -> s3UploadService.delete(p.getPhoto())); + } + reviewRepository.delete(review); } //후기 수정 + @Transactional + public ReviewResponseDto updateReview(Long reviewId, + ReviewUpdateRequestDto dto, + List addPhotos, + Long userId){ + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new IllegalArgumentException("해당 후기가 존재하지 않습니다.")); + + if (!review.getUser().getId().equals(userId)) { + throw new IllegalArgumentException("작성자만 후기를 수정할 수 있습니다."); + } + + 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); + } + + // 사진 추가 + 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 ReviewResponseDto getReviewByUserId(Long userId){ + public ReviewGetResponseDto getReviewById(Long reviewId){ + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new IllegalArgumentException("해당 후기가 존재하지 않습니다.")); + return ReviewGetResponseDto.fromEntity(review); } - */ - // 모임별 후기 조회 + + // 모임별 후기 전체 조회 @Transactional(readOnly = true) - public List getReviewByGroupId(Long groupId){ + public List getReviewByGroupId(Long groupId){ List reviews = reviewRepository.findByGroupId(groupId); return reviews.stream() - .map(ReviewResponseDto::fromEntity) + .map(ReviewGetResponseDto::fromEntity) .toList(); } // 특정 유저의 후기 전체 조회 @Transactional(readOnly = true) - public List getReviewByUserId(Long userId){ + public List getReviewByUserId(Long userId){ List reviews = reviewRepository.findByUserId(userId); return reviews.stream() - .map(ReviewResponseDto::fromEntity) + .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/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/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(); + } +} From 58f26c55d5e4eb17f5d17f04edaa0ed309a9ddc2 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 11 Nov 2025 03:35:18 +0900 Subject: [PATCH 17/78] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20s3=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/service/LocalUploadService.java | 87 ------------------- .../lionsforest/domain/review/Review.java | 2 +- .../dto/response/ReviewGetResponseDto.java | 2 +- .../dto/response/ReviewResponseDto.java | 2 +- src/main/resources/application-deployment.yml | 13 ++- .../resources/application-development.yml | 13 ++- 6 files changed, 27 insertions(+), 92 deletions(-) delete mode 100644 src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java diff --git a/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java b/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java deleted file mode 100644 index d695d21..0000000 --- a/src/main/java/com/example/lionsforest/domain/group/service/LocalUploadService.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.example.lionsforest.domain.group.service; - -import org.springframework.beans.factory.annotation.Value; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import java.io.File; -import java.io.IOException; -import java.util.UUID; - -@Slf4j -@Service -public class LocalUploadService { - @Value("${upload.dir}") // application.yml에서 설정한 경로 - private String uploadDir; - - public String upload(MultipartFile file, String dirName) { - try { - // 1. 파일 원본 이름 - String originalFilename = file.getOriginalFilename(); - // 2. 고유한 파일 이름 생성 (UUID) - String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); - String uniqueFileName = UUID.randomUUID().toString() + extension; - - // 3. 저장할 전체 경로 (예: ./uploads/group-photos/uuid.jpg) - String savePath = uploadDir + dirName + File.separator + uniqueFileName; - - // 4. 로컬 디렉토리 생성 (없으면) - File saveDir = new File(uploadDir + dirName); - if (!saveDir.exists()) { - saveDir.mkdirs(); // mkdirs()로 중간 경로까지 모두 생성 - } - - // 5. 파일 저장 - file.transferTo(new File(savePath)); - - log.info("로컬 파일 저장 성공: {}", savePath); - - // 6. [중요] 웹에서 접근 가능한 URL 반환 - // (예: /uploads/group-photos/uuid.jpg) - // WebConfig 설정이 필요합니다. (다음 단계 참고) - return "/uploads/" + dirName + "/" + uniqueFileName; - - } catch (IOException e) { - log.error("로컬 파일 업로드 실패", e); - throw new RuntimeException("로컬 파일 업로드 중 오류가 발생했습니다.", e); - } - } - - /** - * [신규] 로컬 파일을 삭제하는 메서드 - * @param fileUrl DB에 저장된 웹 경로 (예: /uploads/group-photos/uuid.jpg) - */ - public void delete(String fileUrl) { - if (fileUrl == null || fileUrl.isBlank()) { - log.warn("삭제할 파일 URL이 비어있습니다."); - return; - } - - try { - // 1. 웹 URL을 실제 파일 시스템 경로로 변환 - // (예: /uploads/group-photos/uuid.jpg -> ./uploads/group-photos/uuid.jpg) - String filePath = fileUrl.replaceFirst("/uploads/", uploadDir); - - // 2. OS에 맞게 구분자(Separator) 변경 (예: / -> \) - filePath = filePath.replace("/", File.separator); - - File fileToDelete = new File(filePath); - - // 3. 파일이 존재하는지 확인 - if (fileToDelete.exists()) { - // 4. 파일 삭제 - if (fileToDelete.delete()) { - log.info("로컬 파일 삭제 성공: {}", filePath); - } else { - log.warn("로컬 파일 삭제 실패 (파일은 존재함): {}", filePath); - } - } else { - log.warn("삭제할 파일이 존재하지 않습니다: {}", filePath); - } - } catch (Exception e) { - // SecurityException 등 파일 접근 오류 - log.error("로컬 파일 삭제 중 오류가 발생했습니다: {}", fileUrl, e); - } - } -} diff --git a/src/main/java/com/example/lionsforest/domain/review/Review.java b/src/main/java/com/example/lionsforest/domain/review/Review.java index a2193b8..314464b 100644 --- a/src/main/java/com/example/lionsforest/domain/review/Review.java +++ b/src/main/java/com/example/lionsforest/domain/review/Review.java @@ -18,7 +18,7 @@ public class Review extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long review_id; + private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="group_id", nullable = false) 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 index 7dd7f67..c207967 100644 --- 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 @@ -31,7 +31,7 @@ public static ReviewGetResponseDto fromEntity(Review review){ .toList(); return ReviewGetResponseDto.builder() - .id(review.getReview_id()) + .id(review.getId()) .groupId(review.getGroup().getId()) .userId(review.getUser().getId()) .content(review.getContent()) 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 index e85854b..199c8af 100644 --- 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 @@ -22,7 +22,7 @@ public class ReviewResponseDto { public static ReviewResponseDto fromEntity(Review review){ return ReviewResponseDto.builder() - .id(review.getReview_id()) + .id(review.getId()) .groupId(review.getGroup().getId()) .userId(review.getUser().getId()) .content(review.getContent()) diff --git a/src/main/resources/application-deployment.yml b/src/main/resources/application-deployment.yml index 4a36a24..4b8f9b7 100644 --- a/src/main/resources/application-deployment.yml +++ b/src/main/resources/application-deployment.yml @@ -3,4 +3,15 @@ spring: url: jdbc:mysql://${database.deployment.host}/${database.name} driver-class-name: com.mysql.cj.jdbc.Driver username: ${database.username} - password: ${database.password} \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/main/resources/application-development.yml b/src/main/resources/application-development.yml index ddd39d8..28e7b03 100644 --- a/src/main/resources/application-development.yml +++ b/src/main/resources/application-development.yml @@ -3,4 +3,15 @@ spring: url: jdbc:mysql://${database.development.host}/${database.name} driver-class-name: com.mysql.cj.jdbc.Driver username: ${database.username} - password: ${database.password} \ No newline at end of file + 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 \ No newline at end of file From b65c8e79464b464cf5afcee1f35d8ff7cfed1c21 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 11 Nov 2025 13:32:35 +0900 Subject: [PATCH 18/78] =?UTF-8?q?feat:Swagger=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ .../comment/controller/CommentController.java | 22 +++++++++++-------- .../group/controller/GroupController.java | 8 +++++++ .../controller/ParticipationController.java | 6 +++++ .../review/controller/ReviewController.java | 9 ++++++++ .../global/config/SecurityConfig.java | 3 ++- .../global/config/SwaggerConfig.java | 19 ++++++++++++++++ 7 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java diff --git a/build.gradle b/build.gradle index 260ef3b..a6ccdfd 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,8 @@ dependencies { 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.2.0' } tasks.named('test') { 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 index 04947a2..a93611a 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java +++ b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java @@ -3,24 +3,25 @@ import com.example.lionsforest.domain.comment.dto.request.CommentRequestDto; import com.example.lionsforest.domain.comment.dto.response.CommentResponseDto; import com.example.lionsforest.domain.comment.service.CommentService; -import com.example.lionsforest.domain.user.User; -import com.example.lionsforest.domain.user.repository.UserRepository; 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; -import java.util.Map; @RestController @RequiredArgsConstructor @RequestMapping("/api/comments") +@Tag(name = "댓글", description = "댓글 관련 API") public class CommentController { private final CommentService commentService; // 댓글 생성 @PostMapping("/{group_id}") + @Operation(summary = "댓글 생성", description = "특정 모임(By group_id)에 대한 댓글을 작성합니다") public ResponseEntity createComment(@PathVariable("group_id") Long groupId, @RequestBody CommentRequestDto dto, @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ @@ -29,8 +30,16 @@ public ResponseEntity createComment(@PathVariable("group_id" 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()); @@ -39,14 +48,9 @@ public ResponseEntity deleteComment(@PathVariable("comment_id") Long com return ResponseEntity.ok("댓글이 삭제 되었습니다."); } - // 모임별 댓글 조회 - @GetMapping("/{group_id}") - public ResponseEntity> getCommentByGroup(@PathVariable("group_id") Long groupId){ - return ResponseEntity.ok(commentService.getCommentsByGroupId(groupId)); - } - // 특정 댓글 좋아요 (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 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 index 7e5323d..3bf7eb9 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -7,6 +7,8 @@ import com.example.lionsforest.domain.group.service.GroupService; 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.HttpStatus; import org.springframework.http.MediaType; @@ -20,12 +22,14 @@ @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 = "모임을 개설합니다") public ResponseEntity createGroup(@RequestPart("dto") GroupRequestDto dto, @RequestPart(value = "photos", required = false) List photos, @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ @@ -37,12 +41,14 @@ public ResponseEntity createGroup(@RequestPart("dto") GroupReq // 모임 정보 전체 조회 @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); @@ -50,6 +56,7 @@ public ResponseEntity getGroupByID(@PathVariable("group_id" // 모임 정보 수정 @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){ @@ -60,6 +67,7 @@ public ResponseEntity updateGroup(@PathVariable("group_id") Lo // 모임 삭제 @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()); 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 index c777ea0..cdc562e 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java @@ -5,6 +5,8 @@ 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; @@ -15,12 +17,14 @@ @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()); @@ -30,6 +34,7 @@ public ResponseEntity joinGroup(@PathVariable("group_i // 모임 탈퇴 @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()); @@ -40,6 +45,7 @@ public ResponseEntity leaveGroup(@PathVariable("group_id") Long groupId, // 모임 참여자 조회 @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/review/controller/ReviewController.java b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java index 95c96bd..017ea36 100644 --- a/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java +++ b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java @@ -9,6 +9,8 @@ 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.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -21,11 +23,13 @@ @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 = "특정 모임(By group_id)에 대한 후기를 작성합니다") public ResponseEntity createReview(@PathVariable("group_id") Long groupId, @RequestPart("dto") ReviewRequestDto dto, @RequestPart(value = "photos", required = false) List photos, @@ -37,6 +41,7 @@ public ResponseEntity createReview(@PathVariable("group_id") // 후기 수정 @PatchMapping(value = "/{review_id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "후기 수정", description = "특정 후기(By review_id)를 수정합니다") public ResponseEntity updateReview(@PathVariable("review_id") Long reviewId, @RequestPart("dto") ReviewUpdateRequestDto dto, @RequestPart(value = "addPhotos", required = false) List addPhotos, @@ -48,6 +53,7 @@ public ResponseEntity updateReview(@PathVariable("review_id") // 개별 후기 조회 @GetMapping("/{review_id}") + @Operation(summary = "개별 후기 조회", description = "특정 후기(By review_id)를 조회합니다") public ResponseEntity getReviewById(@PathVariable("review_id") Long reviewId){ return ResponseEntity.ok(reviewService.getReviewById(reviewId)); @@ -55,18 +61,21 @@ public ResponseEntity getReviewById(@PathVariable("review_ // 모임별 후기 조회 @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("group_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){ diff --git a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java index d439eab..37a7290 100644 --- a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -37,7 +37,8 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwtTo // 5. API 엔드포인트별 접근 권한 설정 .authorizeHttpRequests(auth -> auth // "/auth/**" 경로는 인증 없이 무조건 통과(permitAll) - .requestMatchers("/auth/**").permitAll() + .requestMatchers("/auth/**","/swagger-ui/**", "/v3/api-docs/**" + ).permitAll() //api 경로: 인증 되면 통과 .requestMatchers("/api/**").authenticated() .anyRequest().authenticated() 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..325a8de --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java @@ -0,0 +1,19 @@ +package com.example.lionsforest.global.config; + +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.OpenAPI; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("API 제목") + .description("API 설명") + .version("v1.0.0")); + } +} From fa4788f1daf21717c5674121b93a51554f00f34e Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 11 Nov 2025 13:34:33 +0900 Subject: [PATCH 19/78] =?UTF-8?q?feat:Swagger=20=EC=84=A4=EC=A0=95=20.yml?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-deployment.yml | 10 +++++++++- src/main/resources/application-development.yml | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-deployment.yml b/src/main/resources/application-deployment.yml index 4b8f9b7..b2283a3 100644 --- a/src/main/resources/application-deployment.yml +++ b/src/main/resources/application-deployment.yml @@ -4,6 +4,7 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver username: ${database.username} password: ${database.password} + cloud: aws: credentials: @@ -14,4 +15,11 @@ cloud: region: static: ap-northeast-2 stack: - auto: false \ No newline at end of file + 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 index 28e7b03..e00c8c7 100644 --- a/src/main/resources/application-development.yml +++ b/src/main/resources/application-development.yml @@ -4,6 +4,7 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver username: ${database.username} password: ${database.password} + cloud: aws: credentials: @@ -14,4 +15,11 @@ cloud: region: static: ap-northeast-2 stack: - auto: false \ No newline at end of file + auto: false + +# Swagger +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui/index.html \ No newline at end of file From c4199750e53bc0f908dd8756530201639c60e3ca Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 11 Nov 2025 18:41:25 +0900 Subject: [PATCH 20/78] =?UTF-8?q?fix:=20firebase=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20api?= =?UTF-8?q?=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../domain/user/service/AuthService.java | 28 ++++++++--- .../component/FirebaseTokenVerifier.java | 35 ++++++++++++++ .../global/config/FirebaseConfig.java | 37 ++++++++++++++ .../global/config/SecurityConfig.java | 48 +++++++++++++++---- 5 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/global/component/FirebaseTokenVerifier.java create mode 100644 src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java diff --git a/build.gradle b/build.gradle index 260ef3b..fd28560 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,8 @@ dependencies { implementation 'software.amazon.awssdk:s3:2.20.26' // mysql jdbc 드라이버 의존성 runtimeOnly 'com.mysql:mysql-connector-j' + //구글로그인-firebase + implementation 'com.google.firebase:firebase-admin:9.3.0' } tasks.named('test') { 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 index b0529a6..65180aa 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java @@ -6,14 +6,17 @@ import com.example.lionsforest.domain.user.dto.TokenResponseDTO; import com.example.lionsforest.domain.user.dto.UserInfoDTO; import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.component.FirebaseTokenVerifier; import com.example.lionsforest.global.component.GoogleTokenVerifier; import com.example.lionsforest.global.component.MemberWhitelistValidator; import com.example.lionsforest.global.jwt.JwtTokenProvider; +import com.google.firebase.auth.FirebaseAuth; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import javax.naming.AuthenticationException; import java.util.Optional; @Slf4j @@ -25,24 +28,35 @@ public class AuthService { private final UserRepository userRepository; private final MemberWhitelistValidator whitelistValidator; private final JwtTokenProvider jwtTokenProvider; - private final GoogleTokenVerifier googleTokenVerifier; + private final FirebaseTokenVerifier firebaseTokenVerifier; public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { - UserInfoDTO googleUserInfo = googleTokenVerifier.verify(request.getIdToken()); - String name = googleUserInfo.getName(); - String email = googleUserInfo.getEmail(); + //request DTO에서 idToken 꺼내기 + String idToken = request.getIdToken(); + //firebasetokenverifier가 토큰 검증 -> 사용자 정보 추출 + UserInfoDTO userInfo; + try{ + userInfo = firebaseTokenVerifier.verifyIdToken(idToken); + }catch(AuthenticationException e){ + log.error("Invalid ID Token: {}", e.getMessage()); + throw new SecurityException("유효하지 않은 토큰입니다.", e); + } + String name = userInfo.getName(); + String email = userInfo.getEmail(); + //동아리 부원인지 조회 if (!whitelistValidator.isMember(name, email)) { throw new SecurityException("동아리 부원 명단에 존재하지 않거나 정보가 일치하지 않습니다."); } + //첫 가입인지 확인한 후 로그인 시킴 Optional optionalUser = userRepository.findByEmail(email); boolean isNewUser = false; User user; if (optionalUser.isEmpty()) { - user = googleUserInfo.toEntity(); + user = userInfo.toEntity(); userRepository.save(user); isNewUser = true; System.out.println("새 유저 생성!"); @@ -51,10 +65,10 @@ public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { System.out.println("이미 존재하는 유저"); } - // 6. JWT 토큰 생성 (반환 타입이 TokenResponse DTO임) + // JWT 토큰 생성 (반환 타입이 TokenResponse DTO임) TokenResponseDTO tokens = jwtTokenProvider.createTokens(user.getId(), user.getEmail()); - // 7. 응답 DTO 생성 + // 응답 DTO 생성 return LoginResponseDTO.builder() .id(user.getId()) .accessToken(tokens.getAccessToken()) // TokenResponse DTO의 getter 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..f9415b8 --- /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.UserInfoDTO; +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 UserInfoDTO 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 UserInfoDTO.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/config/FirebaseConfig.java b/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java new file mode 100644 index 0000000..1b2e2a8 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java @@ -0,0 +1,37 @@ +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/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java index d439eab..1b0e3cd 100644 --- a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -11,42 +11,74 @@ 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) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwtTokenProvider, CorsConfigurationSource corsConfigurationSource) throws Exception { http - // 1. CSRF 비활성화 + // CORS 설정 추가 + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + // CSRF 비활성화 .csrf(AbstractHttpConfigurer::disable) - // 2. 세션 관리 비활성화 + // 세션 관리 비활성화 .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - // 3. 폼 로그인/HTTP Basic 인증 비활성화 + // 폼 로그인/HTTP Basic 인증 비활성화 .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) - // 4. OAuth2 로그인 페이지 비활성화 + // OAuth2 로그인 페이지 비활성화 // 이걸 꺼야 /auth/google 요청이 컨트롤러로 감 .oauth2Login(AbstractHttpConfigurer::disable) - // 5. API 엔드포인트별 접근 권한 설정 + // API 엔드포인트별 접근 권한 설정 .authorizeHttpRequests(auth -> auth // "/auth/**" 경로는 인증 없이 무조건 통과(permitAll) .requestMatchers("/auth/**").permitAll() - //api 경로: 인증 되면 통과 + //api 및 다른 경로: 인증 되면 통과 .requestMatchers("/api/**").authenticated() .anyRequest().authenticated() ) + //jwt 필터 추가 .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); - // 추후 JWT 필터 추가 필요 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" + )); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); //허용할 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 From 4ca1485c345a62af003f981e7ab260a20cfaa5f3 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 11 Nov 2025 19:07:20 +0900 Subject: [PATCH 21/78] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20gitignore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- .../com/example/lionsforest/global/config/FirebaseConfig.java | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c123ce7..a15b950 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ out/ #Spring 설정 파일 application.yml src/main/resources/application-secret.yml -src/main/resources/members.txt \ No newline at end of file +src/main/resources/members.txt +src/main/resources/firebase-service-account.json \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java b/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java index 1b2e2a8..fe6a003 100644 --- a/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java @@ -31,7 +31,6 @@ public void initializeFirebase(){ }catch(IOException e){ // TODO: 예외처리 e.printStackTrace(); - } } } } From 828b9ae4efb7daad29d8d0509a632306b7e62d02 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 11 Nov 2025 20:07:15 +0900 Subject: [PATCH 22/78] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20api=20?= =?UTF-8?q?=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/domain/user/controller/AuthController.java | 4 ++++ .../lionsforest/domain/user/controller/UserController.java | 6 ++++++ 2 files changed, 10 insertions(+) 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 index 82f90f2..20b1d95 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java @@ -3,6 +3,8 @@ import com.example.lionsforest.domain.user.dto.LoginRequestDTO; import com.example.lionsforest.domain.user.dto.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; @@ -14,12 +16,14 @@ @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) { 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 index ce7cd97..2612f8b 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java @@ -4,6 +4,8 @@ import com.example.lionsforest.domain.user.dto.UserInfoResponseDTO; import com.example.lionsforest.domain.user.dto.UserUpdateRequestDTO; import com.example.lionsforest.domain.user.service.UserService; +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.web.bind.annotation.*; @@ -13,24 +15,28 @@ @RestController @RequestMapping("/api/users") @RequiredArgsConstructor +@Tag(name = "유저", description = "유저 관련 API") public class UserController { private final UserService userService; //유저 목록 조회 @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)); } //유저 정보 수정 @PatchMapping("/{userId}") + @Operation(summary = "유저 정보 수정", description = "user_id 유저의 정보를 수정합니다") public ResponseEntity updateUser( @PathVariable Long userId, @RequestBody UserUpdateRequestDTO request) { From b2d7ab5b0a33853b6e46ec4531e7cd7d8a66d84a Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 11 Nov 2025 20:56:45 +0900 Subject: [PATCH 23/78] =?UTF-8?q?feat:=20swagger=20RequestBody=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/LionsforestApplication.java | 2 + .../comment/controller/CommentController.java | 24 ++++-- .../domain/group/Participation.java | 3 - .../group/controller/GroupController.java | 49 +++++++++--- .../controller/ParticipationController.java | 8 +- .../group/dto/request/GroupRequestDto.java | 33 ++++++++ .../domain/group/service/GroupService.java | 5 +- .../review/controller/ReviewController.java | 80 +++++++++++++++---- .../review/dto/request/ReviewRequestDto.java | 23 ++++++ .../dto/request/ReviewUpdateRequestDto.java | 18 +++++ .../domain/review/service/ReviewService.java | 4 +- 11 files changed, 204 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/example/lionsforest/LionsforestApplication.java b/src/main/java/com/example/lionsforest/LionsforestApplication.java index 5be0b1e..db3bce6 100644 --- a/src/main/java/com/example/lionsforest/LionsforestApplication.java +++ b/src/main/java/com/example/lionsforest/LionsforestApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class LionsforestApplication { public static void main(String[] args) { 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 index a93611a..087a8f2 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java +++ b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java @@ -14,14 +14,26 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/comments") +@RequestMapping("/api/comments/") @Tag(name = "댓글", description = "댓글 관련 API") public class CommentController { private final CommentService commentService; // 댓글 생성 - @PostMapping("/{group_id}") - @Operation(summary = "댓글 생성", description = "특정 모임(By group_id)에 대한 댓글을 작성합니다") + @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){ @@ -31,14 +43,14 @@ public ResponseEntity createComment(@PathVariable("group_id" } // 모임별 댓글 조회 - @GetMapping("/{group_id}") + @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}") + @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) { @@ -49,7 +61,7 @@ public ResponseEntity deleteComment(@PathVariable("comment_id") Long com } // 특정 댓글 좋아요 (Toggle) - @PostMapping("/{comment_id}/like") + @PostMapping("{comment_id}/like/") @Operation(summary = "특정 댓글 좋아요 생성/삭제(Toggle)", description = "특정 댓글(By comment_id)에 대한 좋아요를 생성/삭제") public ResponseEntity toggleLike( @PathVariable("comment_id") Long commentId, diff --git a/src/main/java/com/example/lionsforest/domain/group/Participation.java b/src/main/java/com/example/lionsforest/domain/group/Participation.java index bceed97..d5a4d70 100644 --- a/src/main/java/com/example/lionsforest/domain/group/Participation.java +++ b/src/main/java/com/example/lionsforest/domain/group/Participation.java @@ -25,7 +25,4 @@ public class Participation extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "group_id", nullable = false) private Group group; - - @Column(nullable = false) - private Boolean is_active; } 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 index 3bf7eb9..78e8760 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -5,9 +5,9 @@ import com.example.lionsforest.domain.group.dto.response.GroupGetResponseDto; import com.example.lionsforest.domain.group.dto.response.GroupResponseDto; import com.example.lionsforest.domain.group.service.GroupService; -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.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; @@ -15,13 +15,12 @@ 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/groups") +@RequestMapping("/api/groups/") @Tag(name = "모임", description = "모임 관련 API") public class GroupController { @@ -29,13 +28,41 @@ public class GroupController { // 모임 개설 @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "모임 개설", description = "모임을 개설합니다") - public ResponseEntity createGroup(@RequestPart("dto") GroupRequestDto dto, - @RequestPart(value = "photos", required = false) List photos, + @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(dto, photos, loginUserId); + GroupResponseDto responseDto = groupService.createGroup(req, loginUserId); return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); } @@ -47,7 +74,7 @@ public ResponseEntity> getAllGroups(){ } // 모임 정보 상세 조회 - @GetMapping("/{group_id}") + @GetMapping("{group_id}/") @Operation(summary = "모임 정보 상세 조회", description = "특정 모임(By group_id)에 대한 정보를 조회합니다") public ResponseEntity getGroupByID(@PathVariable("group_id") Long groupId){ GroupGetResponseDto responseDto = groupService.getGroupById(groupId); @@ -55,7 +82,7 @@ public ResponseEntity getGroupByID(@PathVariable("group_id" } // 모임 정보 수정 - @PatchMapping("/{group_id}") + @PatchMapping("{group_id}/") @Operation(summary = "모임 정보 수정", description = "특정 모임(By group_id)의 정보를 수정합니다(사진 제외)") public ResponseEntity updateGroup(@PathVariable("group_id") Long groupId, @RequestBody GroupUpdateRequestDto dto, @@ -66,7 +93,7 @@ public ResponseEntity updateGroup(@PathVariable("group_id") Lo } // 모임 삭제 - @DeleteMapping("/{group_id}") + @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){ 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 index cdc562e..efbcfe6 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java @@ -16,14 +16,14 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/participation") +@RequestMapping("/api/participation/") @Tag(name = "모임 참여", description = "모임 참여 관련 API") public class ParticipationController { private final ParticipationService participationService; private final UserRepository userRepository; // 모임 참여 - @PostMapping("/{group_id}") + @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){ @@ -33,7 +33,7 @@ public ResponseEntity joinGroup(@PathVariable("group_i } // 모임 탈퇴 - @DeleteMapping("/{group_id}") + @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) { @@ -44,7 +44,7 @@ public ResponseEntity leaveGroup(@PathVariable("group_id") Long groupId, } // 모임 참여자 조회 - @GetMapping("/{group_id}") + @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 index 6f22d98..2b690de 100644 --- 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 @@ -3,19 +3,52 @@ 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 = "모임 장소") + @NotNull 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) 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 index 01c7422..0905a5a 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -32,9 +32,7 @@ public class GroupService { // 모임 개설 @Transactional - public GroupResponseDto createGroup(GroupRequestDto dto, - List photos, - Long userId){ + public GroupResponseDto createGroup(GroupRequestDto dto, Long userId){ User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); @@ -42,6 +40,7 @@ public GroupResponseDto createGroup(GroupRequestDto dto, 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++) { 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 index 017ea36..8612a65 100644 --- a/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java +++ b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java @@ -10,6 +10,8 @@ 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; @@ -22,37 +24,83 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/reviews") +@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 = "특정 모임(By group_id)에 대한 후기를 작성합니다") + @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, - @RequestPart("dto") ReviewRequestDto dto, - @RequestPart(value = "photos", required = false) List photos, + @ModelAttribute ReviewRequestDto req, @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ Long loginUserId = Long.valueOf(principal.getUsername()); - return ResponseEntity.ok(reviewService.createReview(groupId, dto, photos, loginUserId)); + return ResponseEntity.ok(reviewService.createReview(groupId, req, loginUserId)); } // 후기 수정 - @PatchMapping(value = "/{review_id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "후기 수정", description = "특정 후기(By review_id)를 수정합니다") + @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, - @RequestPart("dto") ReviewUpdateRequestDto dto, - @RequestPart(value = "addPhotos", required = false) List addPhotos, + @ModelAttribute ReviewUpdateRequestDto req, @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ Long loginUserId = Long.valueOf(principal.getUsername()); - return ResponseEntity.ok(reviewService.updateReview(reviewId, dto, addPhotos, loginUserId)); + return ResponseEntity.ok(reviewService.updateReview(reviewId, req, loginUserId)); } // 개별 후기 조회 - @GetMapping("/{review_id}") + @GetMapping("{review_id}/") @Operation(summary = "개별 후기 조회", description = "특정 후기(By review_id)를 조회합니다") public ResponseEntity getReviewById(@PathVariable("review_id") Long reviewId){ return ResponseEntity.ok(reviewService.getReviewById(reviewId)); @@ -60,21 +108,21 @@ public ResponseEntity getReviewById(@PathVariable("review_ } // 모임별 후기 조회 - @GetMapping("/by-group/{group_id}") + @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}") + @GetMapping("by-user/{user_id}/") @Operation(summary = "유저별 후기 조회", description = "특정 유저(By user_id)에 대한 후기를 조회합니다") - public ResponseEntity> getReviewByUserId(@PathVariable("group_id") Long userId){ + public ResponseEntity> getReviewByUserId(@PathVariable("user_id") Long userId){ return ResponseEntity.ok(reviewService.getReviewByUserId(userId)); } // 후기 삭제 - @DeleteMapping("/{review_id}") + @DeleteMapping("{review_id}/") @Operation(summary = "후기 삭제", description = "특정 후기(review_id)를 삭제합니다") public ResponseEntity deleteReview(@PathVariable("review_id") Long reviewId, @AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ 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 index ad892fa..5a275b4 100644 --- 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 @@ -1,9 +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 index 79fc3c6..e3fb535 100644 --- 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 @@ -1,12 +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/service/ReviewService.java b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java index f83e464..ad0a4d0 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -35,7 +35,6 @@ public class ReviewService { @Transactional public ReviewResponseDto createReview(Long groupId, ReviewRequestDto dto, - List photos, Long userId){ Group group = groupRepository.findById(groupId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); @@ -51,6 +50,7 @@ public ReviewResponseDto createReview(Long groupId, 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++) { @@ -99,7 +99,6 @@ public void deleteReview(Long reviewId, Long userId){ @Transactional public ReviewResponseDto updateReview(Long reviewId, ReviewUpdateRequestDto dto, - List addPhotos, Long userId){ Review review = reviewRepository.findById(reviewId) .orElseThrow(() -> new IllegalArgumentException("해당 후기가 존재하지 않습니다.")); @@ -136,6 +135,7 @@ public ReviewResponseDto updateReview(Long reviewId, review.getPhotos().removeAll(toDelete); } + List addPhotos = dto.getAddPhotos(); // 사진 추가 if (addPhotos != null && !addPhotos.isEmpty()) { // 현재 최대 photo_order 계산 From ddcd3fc6afa54d17fcf6116881800ea5a92fe1d1 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 11 Nov 2025 22:05:38 +0900 Subject: [PATCH 24/78] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B0=8F=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20jwt=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 +-- .../user/controller/UserController.java | 26 +++++++++---- .../user/dto/UserProfileResponseDTO.java | 24 ++++++++++++ .../domain/user/service/UserService.java | 6 ++- .../global/config/SwaggerConfig.java | 34 +++++++++++++++-- .../global/exception/BusinessException.java | 21 +++++++++++ .../global/exception/ErrorCode.java | 29 +++++++++++++++ .../exception/GlobalExceptionHandler.java | 37 +++++++++++++++++++ 8 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/UserProfileResponseDTO.java create mode 100644 src/main/java/com/example/lionsforest/global/exception/BusinessException.java create mode 100644 src/main/java/com/example/lionsforest/global/exception/ErrorCode.java create mode 100644 src/main/java/com/example/lionsforest/global/exception/GlobalExceptionHandler.java diff --git a/build.gradle b/build.gradle index 6dfa69a..2b718b6 100644 --- a/build.gradle +++ b/build.gradle @@ -33,9 +33,6 @@ dependencies { 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 - // Google OAuth2/IdToken 검증 라이브러리 - implementation 'com.google.api-client:google-api-client:2.0.0' - implementation 'com.google.http-client:google-http-client-jackson2:1.44.1' // JJWT (Java JWT) 라이브러리 implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' @@ -47,7 +44,7 @@ dependencies { // mysql jdbc 드라이버 의존성 runtimeOnly 'com.mysql:mysql-connector-j' // 스웨거 - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' //구글로그인-firebase implementation 'com.google.firebase:firebase-admin:9.3.0' } 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 index 2612f8b..e479784 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java @@ -4,10 +4,14 @@ import com.example.lionsforest.domain.user.dto.UserInfoResponseDTO; import com.example.lionsforest.domain.user.dto.UserUpdateRequestDTO; 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.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.apache.coyote.Response; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -34,15 +38,23 @@ public ResponseEntity getUserDetail(@PathVariable Long user return ResponseEntity.ok(userService.getUserInfo(userId)); } - //유저 정보 수정 - @PatchMapping("/{userId}") - @Operation(summary = "유저 정보 수정", description = "user_id 유저의 정보를 수정합니다") - public ResponseEntity updateUser( - @PathVariable Long userId, - @RequestBody UserUpdateRequestDTO request) { + //내 정보 조회 + @GetMapping("/me") + @Operation(summary = "내 정보 조회", description = "마이페이지에서 내 상세 정보를 조회합니다") + public ResponseEntity getMyInfo() { + Long authenticatedUserId = PrincipalHandler.getUserId(); + UserInfoResponseDTO response = userService.getUserInfo(authenticatedUserId); + return ResponseEntity.ok(response); + } + //내 정보 수정 + @PatchMapping("/me") + @Operation(summary = "내 정보 수정", description = "마이페이지에서 내 유저 정보를 수정합니다") + public ResponseEntity updateUser( + @RequestBody @Valid UserUpdateRequestDTO request) { + Long authenticatedUserId = PrincipalHandler.getUserId(); - UserInfoResponseDTO updatedUser = userService.updateUserInfo(userId, request); + UserInfoResponseDTO updatedUser = userService.updateUserInfo(authenticatedUserId, request); return ResponseEntity.ok(updatedUser); } } \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/UserProfileResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/UserProfileResponseDTO.java new file mode 100644 index 0000000..e2b5d79 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/UserProfileResponseDTO.java @@ -0,0 +1,24 @@ +package com.example.lionsforest.domain.user.dto; + +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/service/UserService.java b/src/main/java/com/example/lionsforest/domain/user/service/UserService.java index 31a9f55..aeef3f4 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/UserService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/UserService.java @@ -4,6 +4,8 @@ import com.example.lionsforest.domain.user.dto.UserInfoResponseDTO; import com.example.lionsforest.domain.user.dto.UserUpdateRequestDTO; import com.example.lionsforest.domain.user.repository.UserRepository; +import com.example.lionsforest.global.exception.BusinessException; +import com.example.lionsforest.global.exception.ErrorCode; import com.nimbusds.openid.connect.sdk.UserInfoResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -41,7 +43,7 @@ public UserInfoResponseDTO updateUserInfo(Long userId, UserUpdateRequestDTO requ if (request.getNickname() != null && !request.getNickname().equals(user.getNickname()) && userRepository.existsByNickname(request.getNickname())) { - throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); + throw new BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS); } // User 엔티티 내부의 update 메서드 호출 (JPA 변경 감지) @@ -56,6 +58,6 @@ public UserInfoResponseDTO updateUserInfo(Long userId, UserUpdateRequestDTO requ private User findUserById(Long userId) { return userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다. ID: " + userId)); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); } } \ 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 index 325a8de..bc13aa3 100644 --- a/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java @@ -1,19 +1,45 @@ package com.example.lionsforest.global.config; +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 static java.awt.SystemColor.info; + @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(new Info() - .title("API 제목") - .description("API 설명") - .version("v1.0.0")); + .info(info) // 1번 Info 적용 + .addSecurityItem(securityRequirement) // 3번 SecurityRequirement 적용 + .components(components); // 4번 Components 적용 } } 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..e3b7752 --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -0,0 +1,29 @@ +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", "이미 사용 중인 닉네임입니다."); + + + 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) { + } +} From 282b0380181995e6034253ca216e6453beb04ae5 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 11 Nov 2025 22:38:36 +0900 Subject: [PATCH 25/78] =?UTF-8?q?fix:=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/controller/GroupController.java | 10 ++++++++ .../controller/ParticipationController.java | 9 ++++++++ .../dto/response/GroupGetResponseDto.java | 8 +++++++ .../group/dto/response/GroupResponseDto.java | 8 +++++++ .../response/ParticipationResponseDto.java | 2 ++ .../group/repository/GroupRepository.java | 4 ++++ .../repository/ParticipationRepository.java | 1 + .../domain/group/service/GroupService.java | 23 +++++++++++++++---- .../group/service/ParticipationService.java | 20 +++++++++++++++- .../global/config/FirebaseConfig.java | 2 +- 10 files changed, 81 insertions(+), 6 deletions(-) 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 index 78e8760..68d408b 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -81,6 +81,16 @@ public ResponseEntity getGroupByID(@PathVariable("group_id" 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)의 정보를 수정합니다(사진 제외)") 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 index efbcfe6..594611d 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java @@ -32,6 +32,15 @@ public ResponseEntity joinGroup(@PathVariable("group_i 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)에서 탈퇴합니다") 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 index 8560a53..05016c3 100644 --- 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 @@ -17,12 +17,16 @@ @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; @@ -35,6 +39,9 @@ public static GroupGetResponseDto fromEntity(Group group){ 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()) @@ -42,6 +49,7 @@ public static GroupGetResponseDto fromEntity(Group group){ .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/GroupResponseDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/GroupResponseDto.java index d6da62a..5ddf22b 100644 --- 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 @@ -14,22 +14,30 @@ @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 int 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/ParticipationResponseDto.java b/src/main/java/com/example/lionsforest/domain/group/dto/response/ParticipationResponseDto.java index 20dbbb4..6bcd216 100644 --- 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 @@ -15,6 +15,7 @@ public class ParticipationResponseDto { private Long groupId; private Long userId; private String userName; + private String userNickname; private LocalDateTime createdAt; public static ParticipationResponseDto fromEntity(Participation participation){ @@ -23,6 +24,7 @@ public static ParticipationResponseDto fromEntity(Participation participation){ .groupId(participation.getGroup().getId()) .userId(participation.getUser().getId()) .userName(participation.getUser().getName()) + .userNickname(participation.getUser().getNickname()) .createdAt(participation.getCreatedAt()) .build(); } 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 index 523af51..989f16b 100644 --- a/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java @@ -4,9 +4,13 @@ import com.example.lionsforest.domain.group.Group; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; + +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); } 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 index 54089e6..317aea3 100644 --- a/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java +++ b/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java @@ -17,4 +17,5 @@ public interface ParticipationRepository extends JpaRepository findByGroupIdAndUserId(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 index 0905a5a..7b90a1d 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -1,6 +1,7 @@ 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; @@ -8,6 +9,7 @@ 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.user.User; @@ -29,6 +31,7 @@ public class GroupService { private final GroupPhotoRepository groupPhotoRepository; private final UserRepository userRepository; private final S3UploadService s3UploadService; + private final ParticipationRepository participationRepository; // 모임 개설 @Transactional @@ -61,10 +64,14 @@ public GroupResponseDto createGroup(GroupRequestDto dto, Long userId){ groupPhotoRepository.saveAll(groupPhotos); } - return new GroupResponseDto(saved.getId(), - saved.getTitle(), saved.getCategory(), - saved.getCapacity(), saved.getMeetingAt(), - saved.getLocation(), saved.getState()); + // 모임장은 모임 자동 참여 + Participation leaderParticipation = Participation.builder() + .group(saved) + .user(user) + .build(); + participationRepository.save(leaderParticipation); + + return GroupResponseDto.fromEntity(saved); } // 모임 정보 전체 조회 @@ -86,6 +93,14 @@ public GroupGetResponseDto getGroupById(Long groupId) { 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){ 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 index eb683d4..d1f8e6a 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -1,6 +1,7 @@ package com.example.lionsforest.domain.group.service; import com.example.lionsforest.domain.group.Group; +import com.example.lionsforest.domain.group.GroupState; import com.example.lionsforest.domain.group.repository.GroupRepository; import com.example.lionsforest.domain.group.repository.ParticipationRepository; import com.example.lionsforest.domain.user.User; @@ -38,7 +39,8 @@ public ParticipationResponseDto joinGroup(Long groupId, Long userId){ // 인원 제한 체크 long currentCount = participationRepository.countByGroupId(groupId); - if(currentCount >= group.getCapacity()) { + int capacity = group.getCapacity(); + if(currentCount >= capacity) { throw new IllegalArgumentException("모임 인원이 가득 찼습니다."); } @@ -48,9 +50,25 @@ public ParticipationResponseDto joinGroup(Long groupId, Long userId){ .build(); Participation saved = participationRepository.save(participation); + + 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(ParticipationResponseDto::fromEntity) + .toList(); + } + // 모임 탈퇴 @Transactional public void leaveGroup(Long groupId, Long userId) { diff --git a/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java b/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java index 1b2e2a8..f459bf5 100644 --- a/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/FirebaseConfig.java @@ -32,6 +32,6 @@ public void initializeFirebase(){ // TODO: 예외처리 e.printStackTrace(); } - } } } + From 2f761712cdc52f1da519a0762af337e369904629 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 11 Nov 2025 23:45:01 +0900 Subject: [PATCH 26/78] =?UTF-8?q?fix:=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/group/service/ParticipationService.java | 7 +++++++ 1 file changed, 7 insertions(+) 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 index d1f8e6a..054f198 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -72,6 +72,8 @@ public List getAllMyParticipations(Long userId){ // 모임 탈퇴 @Transactional public void leaveGroup(Long groupId, Long userId) { + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); @@ -81,6 +83,11 @@ public void leaveGroup(Long groupId, Long userId) { .orElseThrow(() -> new IllegalArgumentException("참여하지 않은 모임입니다.")); participationRepository.delete(participation); + + long after = participationRepository.countByGroupId(groupId); + if (after < group.getCapacity() && group.getState() == GroupState.CLOSED) { + group.setState(GroupState.OPEN); // 다시 모집중 + } } // 모임 참여자 조회 From e45f6e46fb811e27e72d9df8a089a625c86cc528 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Wed, 12 Nov 2025 00:37:06 +0900 Subject: [PATCH 27/78] =?UTF-8?q?feat:=20swagger=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/lionsforest/global/config/SwaggerConfig.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java b/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java index bc13aa3..9a2918c 100644 --- a/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java @@ -1,5 +1,7 @@ package com.example.lionsforest.global.config; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.OpenAPI; @@ -10,6 +12,11 @@ import static java.awt.SystemColor.info; +@OpenAPIDefinition( + servers = { + @Server(url = "https://api.lions-forest.p-e.kr", description = "Production server URL") + } +) @Configuration public class SwaggerConfig { From 5b42c99e05500d7d2b87c8b92db5bfa7c8c8ab6d Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Wed, 12 Nov 2025 01:26:14 +0900 Subject: [PATCH 28/78] =?UTF-8?q?feat:=20=EC=9E=84=EC=8B=9C=20jwt=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/SecurityConfig.java | 6 +++- .../lionsforest/TokenGenerationTest.java | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/example/lionsforest/TokenGenerationTest.java diff --git a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java index a7fad4e..afd2573 100644 --- a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -66,10 +66,14 @@ public CorsConfigurationSource corsConfigurationSource() { //허용할 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" + "http://localhost:5173", + //백엔드 주소 + "https://api.lions-forest.p-e.kr", + "http://localhost:8080" )); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); //허용할 http 메서드 config.setAllowedHeaders(Arrays.asList("*")); //모든 http 헤더 허용 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..62831ed --- /dev/null +++ b/src/test/java/com/example/lionsforest/TokenGenerationTest.java @@ -0,0 +1,30 @@ +package com.example.lionsforest; + +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; + +@SpringBootTest +class TokenGenerationTest { // 클래스 이름은 아무거나 상관없습니다. + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Test + void generateTestToken() { + // --- 여기만 수정 --- + Long testUserId = 1L; // DB에 있는 테스트용 유저 ID + String testUserEmail = "test@example.com"; // 해당 유저 이메일 + // ----------------- + + // 토큰 생성 + 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 From 54a79ab04255d3c30fc805d8ae5a6d1cb5d3abab Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Wed, 12 Nov 2025 02:51:28 +0900 Subject: [PATCH 29/78] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20fix:=20=EA=B8=B0=EC=A1=B4=20API=20?= =?UTF-8?q?=EB=94=94=ED=85=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/service/CommentService.java | 53 +++++++++++++++++++ .../lionsforest/domain/group/GroupPhoto.java | 2 +- .../dto/response/GroupGetResponseDto.java | 2 +- .../group/dto/response/GroupPhotoDto.java | 2 +- .../repository/GroupPhotoRepository.java | 4 ++ .../repository/ParticipationRepository.java | 2 + .../domain/group/service/GroupService.java | 40 +++++++++++++- .../group/service/ParticipationService.java | 29 +++++++++- .../domain/notification/Notification.java | 10 ++-- .../controller/NotificationController.java | 53 +++++++++++++++++++ .../dto/response/NotificationResponseDto.java | 28 ++++++++++ .../repository/NotificationRepository.java | 14 +++++ .../domain/review/service/ReviewService.java | 38 +++++++++++++ 13 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java create mode 100644 src/main/java/com/example/lionsforest/domain/notification/dto/response/NotificationResponseDto.java create mode 100644 src/main/java/com/example/lionsforest/domain/notification/repository/NotificationRepository.java 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 index fb8efcc..5fb5e97 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -5,14 +5,22 @@ 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.repository.NotificationRepository; import com.example.lionsforest.domain.user.User; import com.example.lionsforest.domain.user.repository.UserRepository; 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 @@ -21,6 +29,9 @@ 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 @@ -38,6 +49,31 @@ public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, Lon .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) + .build(); + notificationRepository.save(notification); + } + } + return CommentResponseDto.fromEntity(saved); } @@ -87,6 +123,23 @@ public String toggleLike(Long commentId, Long userId){ } 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(); + } + Notification notification = Notification.builder() + .user(author) + .content("♥️ 내가 작성한 댓글에 하트가 달렸어요.") + .photo(photoPath) + .build(); + notificationRepository.save(notification); + } + return "좋아요가 추가되었습니다."; } } diff --git a/src/main/java/com/example/lionsforest/domain/group/GroupPhoto.java b/src/main/java/com/example/lionsforest/domain/group/GroupPhoto.java index 21fab61..5d05bec 100644 --- a/src/main/java/com/example/lionsforest/domain/group/GroupPhoto.java +++ b/src/main/java/com/example/lionsforest/domain/group/GroupPhoto.java @@ -24,5 +24,5 @@ public class GroupPhoto { private String photo; @Column(nullable = false) - private Integer photo_order; + private Integer photoOrder; } 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 index 05016c3..fc146a8 100644 --- 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 @@ -33,7 +33,7 @@ public class GroupGetResponseDto { public static GroupGetResponseDto fromEntity(Group group){ List photos = group.getPhotos().stream() - .sorted(Comparator.comparing(GroupPhoto::getPhoto_order)) + .sorted(Comparator.comparing(GroupPhoto::getPhotoOrder)) .map(GroupPhotoDto::new) .toList(); 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 index ee20227..2e589d0 100644 --- 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 @@ -11,6 +11,6 @@ public class GroupPhotoDto { public GroupPhotoDto(GroupPhoto photo) { this.photoUrl = photo.getPhoto(); - this.order = photo.getPhoto_order(); + this.order = photo.getPhotoOrder(); } } 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 index 83d299f..7222391 100644 --- a/src/main/java/com/example/lionsforest/domain/group/repository/GroupPhotoRepository.java +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupPhotoRepository.java @@ -5,8 +5,12 @@ 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/ParticipationRepository.java b/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java index 317aea3..4958128 100644 --- a/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java +++ b/src/main/java/com/example/lionsforest/domain/group/repository/ParticipationRepository.java @@ -16,6 +16,8 @@ public interface ParticipationRepository extends JpaRepository 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 index 7b90a1d..a150504 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -10,6 +10,8 @@ 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; @@ -20,8 +22,11 @@ import org.springframework.stereotype.Service; 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 @@ -32,6 +37,7 @@ public class GroupService { private final UserRepository userRepository; private final S3UploadService s3UploadService; private final ParticipationRepository participationRepository; + private final NotificationRepository notificationRepository; // 모임 개설 @Transactional @@ -55,7 +61,7 @@ public GroupResponseDto createGroup(GroupRequestDto dto, Long userId){ GroupPhoto groupPhoto = GroupPhoto.builder() .group(saved) // 저장된 Group 객체 .photo(photoUrl) // S3에서 반환된 URL - .photo_order(i) // 사진 순서 (0부터 시작) + .photoOrder(i) // 사진 순서 (0부터 시작) .build(); groupPhotos.add(groupPhoto); @@ -151,10 +157,40 @@ public void deleteGroup(Long groupId, Long userId){ User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + // 모임 취소 시점 제한 + if (group.getMeetingAt().isBefore(LocalDateTime.now())) { + throw new IllegalStateException("이미 종료된 모임은 취소할 수 없습니다."); + } + // 유저 권한 확인 if(!group.getLeader().getId().equals(user.getId())){ - throw new IllegalArgumentException("모임장만 모임을 수정할 수 있습니다."); + throw new IllegalArgumentException("모임장만 모임을 삭제할 수 있습니다."); + } + + // 알림 생성: 모임 참가자들에게 모임 취소 알림 보내기 + // 해당 모임의 모든 참여 관계 조회 (모임장 제외) + 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) { 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 index 054f198..fd2e355 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -1,20 +1,25 @@ 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.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.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.domain.user.repository.UserRepository; 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 @@ -22,6 +27,8 @@ public class ParticipationService { private final ParticipationRepository participationRepository; private final GroupRepository groupRepository; private final UserRepository userRepository; + private final GroupPhotoRepository groupPhotoRepository; + private final NotificationRepository notificationRepository; // 모임 참여 @Transactional @@ -51,6 +58,22 @@ public ParticipationResponseDto joinGroup(Long groupId, Long userId){ 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) + .build(); + notificationRepository.save(notification); + long after = currentCount + 1; if (after >= capacity && group.getState() != GroupState.CLOSED) { group.setState(GroupState.CLOSED); // 모집완료로 전환 @@ -78,6 +101,10 @@ public void leaveGroup(Long groupId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + if (group.getLeader().getId().equals(userId)) { + throw new IllegalArgumentException("모임장은 모임을 탈퇴할 수 없습니다."); + } + Participation participation = participationRepository .findByGroupIdAndUserId(groupId, user.getId()) .orElseThrow(() -> new IllegalArgumentException("참여하지 않은 모임입니다.")); diff --git a/src/main/java/com/example/lionsforest/domain/notification/Notification.java b/src/main/java/com/example/lionsforest/domain/notification/Notification.java index fa532d7..d624d34 100644 --- a/src/main/java/com/example/lionsforest/domain/notification/Notification.java +++ b/src/main/java/com/example/lionsforest/domain/notification/Notification.java @@ -1,20 +1,19 @@ 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; -import java.time.LocalDateTime; - @Entity @Getter @Builder @AllArgsConstructor @NoArgsConstructor -public class Notification { +public class Notification extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -29,8 +28,7 @@ public class Notification { private String photo; @Column(nullable = false) - private boolean is_read; + private boolean isRead = false; - @Column(nullable = false, updatable = false) - private LocalDateTime created_at; + public void markRead() { this.isRead = true; } } 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..c55f111 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java @@ -0,0 +1,53 @@ +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +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; + + // 알림 목록 조회 + @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); + } +} + 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..c90d4aa --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/dto/response/NotificationResponseDto.java @@ -0,0 +1,28 @@ +package com.example.lionsforest.domain.notification.dto.response; + +import com.example.lionsforest.domain.notification.Notification; +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 boolean read; + private LocalDateTime createdAt; + + public static NotificationResponseDto fromEntity(Notification notification) { + return NotificationResponseDto.builder() + .id(notification.getId()) + .content(notification.getContent()) + .photo(notification.getPhoto()) + .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..692ea3a --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,14 @@ +package com.example.lionsforest.domain.notification.repository; + +import com.example.lionsforest.domain.notification.Notification; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NotificationRepository extends JpaRepository { + // 특정 유저의 모든 알림 최신순 조회 + List findAllByUserIdOrderByCreatedAtDesc(Long userId); + + // 안 읽은 알림 개수 조회 + long countByUserIdAndIsReadFalse(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 index ad0a4d0..0348c48 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -1,7 +1,13 @@ 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.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; @@ -19,17 +25,22 @@ 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 @@ -41,6 +52,10 @@ public ReviewResponseDto createReview(Long groupId, User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + if (!participationRepository.existsByGroupIdAndUserId(groupId, userId)) { + throw new IllegalArgumentException("참여한 모임에만 후기를 작성할 수 있습니다."); + } + Review review = Review.builder() .group(group) .score(dto.getScore()) @@ -71,6 +86,29 @@ public ReviewResponseDto createReview(Long groupId, 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) + .build(); + notificationRepository.save(notification); + } + } + return ReviewResponseDto.fromEntity(saved); } From 7cf8234fae690c19d226b5ac56575ed22718a733 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Wed, 12 Nov 2025 12:16:05 +0900 Subject: [PATCH 30/78] =?UTF-8?q?fix:=20=EC=9E=84=EC=8B=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/TokenGenerationTest.java | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/example/lionsforest/TokenGenerationTest.java b/src/test/java/com/example/lionsforest/TokenGenerationTest.java index 62831ed..a89e882 100644 --- a/src/test/java/com/example/lionsforest/TokenGenerationTest.java +++ b/src/test/java/com/example/lionsforest/TokenGenerationTest.java @@ -1,9 +1,13 @@ 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 { // 클래스 이름은 아무거나 상관없습니다. @@ -11,12 +15,24 @@ class TokenGenerationTest { // 클래스 이름은 아무거나 상관없습니 @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired + private UserRepository userRepository; + @Test + @Transactional + @Commit void generateTestToken() { - // --- 여기만 수정 --- - Long testUserId = 1L; // DB에 있는 테스트용 유저 ID - String testUserEmail = "test@example.com"; // 해당 유저 이메일 - // ----------------- + //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); From e99dc21fe42d3a129fdf6e83a949a9e2ffc89822 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Wed, 12 Nov 2025 12:17:22 +0900 Subject: [PATCH 31/78] =?UTF-8?q?feat:=20cors=20=ED=97=88=EC=9A=A9=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/lionsforest/global/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java index afd2573..f776241 100644 --- a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -71,6 +71,7 @@ public CorsConfigurationSource corsConfigurationSource() { "http://lions-forest.p-e.kr", "http://localhost:3000", "http://localhost:5173", + "https://lionforest-dev.netlify.app", //백엔드 주소 "https://api.lions-forest.p-e.kr", "http://localhost:8080" From 0eccf19ebeb785a41d442d2b2b1946bf72acf001 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Wed, 12 Nov 2025 13:29:59 +0900 Subject: [PATCH 32/78] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/TempAuthController.java | 52 +++++++++++++++++++ .../global/config/SwaggerConfig.java | 26 +++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java 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..ee65dc9 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java @@ -0,0 +1,52 @@ +package com.example.lionsforest.domain.user.controller; + +import com.example.lionsforest.domain.user.User; +import com.example.lionsforest.domain.user.dto.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 + ) { + // 1. 테스트 코드의 로직과 동일: 유저 조회 또는 생성 + User user = userRepository.findByEmail(email) + .orElseGet(() -> { + // User 엔티티 빌더에 맞게 수정하세요 + User newUser = User.builder() + .email(email) + .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); + } +} diff --git a/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java b/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java index 9a2918c..b03742a 100644 --- a/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SwaggerConfig.java @@ -1,7 +1,6 @@ package com.example.lionsforest.global.config; import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.OpenAPI; @@ -9,14 +8,12 @@ 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( - servers = { - @Server(url = "https://api.lions-forest.p-e.kr", description = "Production server URL") - } -) +@OpenAPIDefinition @Configuration public class SwaggerConfig { @@ -49,4 +46,21 @@ public OpenAPI openAPI() { .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")); + } } From 681c8c3926c5ed845df776fd851cb66f01f1426f Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Wed, 12 Nov 2025 14:04:23 +0900 Subject: [PATCH 33/78] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=EC=9D=B8=EC=A6=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/component/MemberWhitelistValidator.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java b/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java index fc7de4d..9847711 100644 --- a/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java +++ b/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java @@ -37,6 +37,9 @@ public void loadWhitelist() { } } log.info("Loaded {} members into whitelist", whitelist.size()); + // 맵에 실제로 저장된 Key-Value 쌍 전체를 출력 + log.info("Whitelist contents: {}", whitelist); + }catch(IOException e){ log.error("Failed to load whitelist", e); } @@ -46,7 +49,7 @@ public void loadWhitelist() { public boolean isMember(String googleName, String googleEmail) { //whitelist에 이메일 키 있는지 검증 if(!whitelist.containsKey(googleEmail)){ - log.warn("User {} does not have a whitelisted member", googleEmail); + log.warn("User {} does not have a whitelisted member. (Email NOT FOUND)", googleEmail); return false; } @@ -54,9 +57,9 @@ public boolean isMember(String googleName, String googleEmail) { String whitelistName = whitelist.get(googleEmail); boolean isMatch = googleName.equals(whitelistName); if(!isMatch){ - log.warn("User {} does not have a whitelisted member", googleEmail); + log.info("Name mismatch (but allowing login): Google='{}', Whitelist='{}'", googleName, whitelistName); } - return isMatch; + return true; } } From 5bc474c73e5629cd557bc7925fcbb5582bb5d500 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Thu, 13 Nov 2025 00:38:49 +0900 Subject: [PATCH 34/78] =?UTF-8?q?fix:=20=EC=9E=84=EC=8B=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20api=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/controller/TempAuthController.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index ee65dc9..c0aeee0 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java @@ -27,7 +27,8 @@ public TempAuthController(UserRepository userRepository, JwtTokenProvider jwtTok @GetMapping("/auth/test-token") // SecurityConfig에서 /auth/** 는 permitAll 이라 접근 가능 @Operation(summary = "임시 로그인", description = "임시로 유저 로그인을 처리하고 액세스 토큰을 발급합니다") public ResponseEntity getTestToken( - @RequestParam(defaultValue = "test@example.com") String email + @RequestParam(defaultValue = "test@example.com") String email, + @RequestParam(defaultValue = "테스트유저") String name ) { // 1. 테스트 코드의 로직과 동일: 유저 조회 또는 생성 User user = userRepository.findByEmail(email) @@ -35,7 +36,7 @@ public ResponseEntity getTestToken( // User 엔티티 빌더에 맞게 수정하세요 User newUser = User.builder() .email(email) - .name("테스트유저") + .name(name) .bio("") .nickname("") .profile_photo(null) From acad44b749bfa77fb67888302a08f0157412a79b Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Thu, 13 Nov 2025 02:24:45 +0900 Subject: [PATCH 35/78] =?UTF-8?q?feat:=EC=95=8C=EB=A6=BC=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/LionsforestApplication.java | 2 + .../controller/ParticipationController.java | 3 +- .../group/dto/request/GroupRequestDto.java | 2 +- .../group/dto/response/GroupResponseDto.java | 4 +- .../group/repository/GroupRepository.java | 3 + .../domain/group/service/GroupService.java | 5 +- .../group/service/ParticipationService.java | 5 +- .../repository/NotificationRepository.java | 16 +++++ .../scheduler/NotificationScheduler.java | 67 +++++++++++++++++++ .../service/NotificationCleanupService.java | 26 +++++++ 10 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/domain/notification/scheduler/NotificationScheduler.java create mode 100644 src/main/java/com/example/lionsforest/domain/notification/service/NotificationCleanupService.java diff --git a/src/main/java/com/example/lionsforest/LionsforestApplication.java b/src/main/java/com/example/lionsforest/LionsforestApplication.java index db3bce6..e826e3e 100644 --- a/src/main/java/com/example/lionsforest/LionsforestApplication.java +++ b/src/main/java/com/example/lionsforest/LionsforestApplication.java @@ -3,9 +3,11 @@ 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) { 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 index 594611d..b209179 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/ParticipationController.java @@ -1,5 +1,6 @@ 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; @@ -35,7 +36,7 @@ public ResponseEntity joinGroup(@PathVariable("group_i // 내가 참여한 모임 조회 @GetMapping("my/") @Operation(summary = "내가 참여한 모임 조회", description = "내가 참여한 모임을 모두 조회합니다") - public ResponseEntity> getUser(@AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ + public ResponseEntity> getUser(@AuthenticationPrincipal org.springframework.security.core.userdetails.User principal){ Long loginUserId = Long.valueOf(principal.getUsername()); return ResponseEntity.ok(participationService.getAllMyParticipations(loginUserId)); 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 index 2b690de..9960752 100644 --- 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 @@ -40,7 +40,7 @@ public class GroupRequestDto { private LocalDateTime meetingAt; @Schema(description = "모임 장소") - @NotNull + @NotBlank private String location; @ArraySchema( 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 index 5ddf22b..cc1cfea 100644 --- 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 @@ -6,9 +6,11 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.Setter; import java.time.LocalDateTime; +@Setter @Getter @Builder @AllArgsConstructor @@ -23,7 +25,7 @@ public class GroupResponseDto { private LocalDateTime meetingAt; private String location; private GroupState state; - private int participantCount; + private long participantCount; public static GroupResponseDto fromEntity(Group group){ return GroupResponseDto.builder() 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 index 989f16b..1291f9d 100644 --- a/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java @@ -5,6 +5,7 @@ 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 { @@ -13,4 +14,6 @@ public interface GroupRepository extends JpaRepository { Optional findByIdWithPhotos(@Param("id") Long id); List findAllByLeaderId(Long leaderId); + + List findByMeetingAtBetween(LocalDateTime startRange, LocalDateTime endRange); } 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 index a150504..bd5fa2a 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -77,7 +77,10 @@ public GroupResponseDto createGroup(GroupRequestDto dto, Long userId){ .build(); participationRepository.save(leaderParticipation); - return GroupResponseDto.fromEntity(saved); + long participantCount = participationRepository.countByGroupId(saved.getId()); + GroupResponseDto response = GroupResponseDto.fromEntity(saved); + response.setParticipantCount(participantCount); + return response; } // 모임 정보 전체 조회 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 index fd2e355..ad7f61f 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -3,6 +3,7 @@ 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; @@ -84,11 +85,11 @@ public ParticipationResponseDto joinGroup(Long groupId, Long userId){ // 내가 참여한 모임 목록 조회 @Transactional(readOnly = true) - public List getAllMyParticipations(Long userId){ + public List getAllMyParticipations(Long userId){ List participations = participationRepository.findByUserId(userId); return participations.stream() - .map(ParticipationResponseDto::fromEntity) + .map(participation -> GroupGetResponseDto.fromEntity(participation.getGroup())) .toList(); } 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 index 692ea3a..feff12f 100644 --- a/src/main/java/com/example/lionsforest/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/example/lionsforest/domain/notification/repository/NotificationRepository.java @@ -1,8 +1,16 @@ 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 { @@ -11,4 +19,12 @@ public interface NotificationRepository extends JpaRepository 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) + .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); + } +} + From 055d9d59ae3f89ba5800e6999685d2840224c68f Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Thu, 13 Nov 2025 03:13:45 +0900 Subject: [PATCH 36/78] =?UTF-8?q?fix:=20=EB=AA=A8=EC=9E=84=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/lionsforest/domain/group/GroupCategory.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java b/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java index 3864638..9be5930 100644 --- a/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java +++ b/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java @@ -3,7 +3,6 @@ public enum GroupCategory { MEAL, //식사 WORK, //모각작 - CAFE, //카페 SOCIAL, //소모임 CULTURE, //문화예술 ETC //기타 From 7016d23133b2910722a54137e5a810dfec7916a8 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Thu, 13 Nov 2025 03:13:45 +0900 Subject: [PATCH 37/78] =?UTF-8?q?fix:=20=EB=AA=A8=EC=9E=84=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/lionsforest/domain/group/GroupCategory.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java b/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java index 3864638..9be5930 100644 --- a/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java +++ b/src/main/java/com/example/lionsforest/domain/group/GroupCategory.java @@ -3,7 +3,6 @@ public enum GroupCategory { MEAL, //식사 WORK, //모각작 - CAFE, //카페 SOCIAL, //소모임 CULTURE, //문화예술 ETC //기타 From b1e89bc651b82da4924d05e020cc687ff6bd996d Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Thu, 13 Nov 2025 15:31:00 +0900 Subject: [PATCH 38/78] =?UTF-8?q?feat:=ED=9B=84=EA=B8=B0=20response?= =?UTF-8?q?=EC=97=90=20groupTitle=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/review/dto/response/ReviewGetResponseDto.java | 2 ++ 1 file changed, 2 insertions(+) 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 index c207967..240d60b 100644 --- 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 @@ -17,6 +17,7 @@ public class ReviewGetResponseDto { private Long id; private Long groupId; private Long userId; + private String groupTitle; private String content; private Integer score; private LocalDateTime createdAt; @@ -34,6 +35,7 @@ public static ReviewGetResponseDto fromEntity(Review review){ .id(review.getId()) .groupId(review.getGroup().getId()) .userId(review.getUser().getId()) + .groupTitle(review.getGroup().getTitle()) .content(review.getContent()) .score(review.getScore()) .createdAt(review.getCreatedAt()) From 8fc167d169e11329646e2e61f67d4f42809a0cec Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Thu, 13 Nov 2025 19:33:23 +0900 Subject: [PATCH 39/78] =?UTF-8?q?fix:=20response=20=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/comment/dto/response/CommentResponseDto.java | 4 ++++ .../domain/group/dto/response/ParticipationResponseDto.java | 2 ++ .../domain/review/dto/response/ReviewGetResponseDto.java | 6 ++++++ 3 files changed, 12 insertions(+) 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 index fe5d87f..028e3ff 100644 --- 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 @@ -14,7 +14,9 @@ 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; @@ -24,7 +26,9 @@ public static CommentResponseDto fromEntity(Comment comment){ .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.getLiked_by_users().size()) // 좋아요 수 .createdAt(comment.getCreatedAt()) 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 index 6bcd216..b14db8c 100644 --- 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 @@ -16,6 +16,7 @@ public class ParticipationResponseDto { private Long userId; private String userName; private String userNickname; + private String profilePhotoUrl; private LocalDateTime createdAt; public static ParticipationResponseDto fromEntity(Participation participation){ @@ -25,6 +26,7 @@ public static ParticipationResponseDto fromEntity(Participation participation){ .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/review/dto/response/ReviewGetResponseDto.java b/src/main/java/com/example/lionsforest/domain/review/dto/response/ReviewGetResponseDto.java index 240d60b..9288912 100644 --- 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 @@ -17,6 +17,9 @@ 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; @@ -35,6 +38,9 @@ public static ReviewGetResponseDto fromEntity(Review review){ .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()) From 71ded515e70c6e6065e5075ca235c1109e577af7 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Thu, 13 Nov 2025 20:46:35 +0900 Subject: [PATCH 40/78] =?UTF-8?q?fix:=20=EB=82=B4=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20api=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/lionsforest/domain/user/User.java | 22 ++++-------- .../user/controller/UserController.java | 15 ++++++-- .../domain/user/dto/UserUpdateRequestDTO.java | 6 +++- .../domain/user/service/UserService.java | 35 +++++++++++++++---- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/example/lionsforest/domain/user/User.java b/src/main/java/com/example/lionsforest/domain/user/User.java index 47735cc..e905f3c 100644 --- a/src/main/java/com/example/lionsforest/domain/user/User.java +++ b/src/main/java/com/example/lionsforest/domain/user/User.java @@ -8,10 +8,7 @@ import com.example.lionsforest.domain.user.dto.UserInfoResponseDTO; import com.example.lionsforest.global.common.BaseTimeEntity; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; @@ -21,6 +18,7 @@ @Entity @Getter +@Setter @Builder @AllArgsConstructor @NoArgsConstructor @@ -94,16 +92,10 @@ public class User extends BaseTimeEntity { //메서드 // 유저 프로필 수정 - public void updateProfile(String nickname, String bio, String profile_photo) { - //null이 아닐 때만 필드 업데이트 - if(nickname != null & nickname.isBlank()){ - this.nickname = nickname; - } - if(bio != null & bio.isBlank()){ - this.bio = bio; - } - if(profile_photo != null & profile_photo.isBlank()){ - this.profile_photo = profile_photo; - } + // 닉네임 & 바이오 - 항상 업데이트 + 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/UserController.java b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java index e479784..0f339d5 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java @@ -6,10 +6,15 @@ 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.apache.coyote.Response; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -48,10 +53,16 @@ public ResponseEntity getMyInfo() { } //내 정보 수정 - @PatchMapping("/me") + @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( - @RequestBody @Valid UserUpdateRequestDTO request) { + @ModelAttribute UserUpdateRequestDTO request) { Long authenticatedUserId = PrincipalHandler.getUserId(); UserInfoResponseDTO updatedUser = userService.updateUserInfo(authenticatedUserId, request); diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java index fd570e7..4e097be 100644 --- a/src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java +++ b/src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java @@ -1,11 +1,15 @@ package com.example.lionsforest.domain.user.dto; import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; // 유저 정보 수정에 사용 @Getter +@Setter public class UserUpdateRequestDTO { private String nickname; private String bio; - private String profile_photo; + private MultipartFile photo; + private Boolean removePhoto; } 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 index aeef3f4..d0253a4 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/UserService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/UserService.java @@ -4,12 +4,14 @@ import com.example.lionsforest.domain.user.dto.UserInfoResponseDTO; import com.example.lionsforest.domain.user.dto.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 com.nimbusds.openid.connect.sdk.UserInfoResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.stream.Collectors; @@ -20,6 +22,7 @@ public class UserService { private final UserRepository userRepository; + private final S3UploadService s3UploadService; //유저 목록 전체 조회 public List getAllUsers() { @@ -46,12 +49,32 @@ public UserInfoResponseDTO updateUserInfo(Long userId, UserUpdateRequestDTO requ throw new BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS); } - // User 엔티티 내부의 update 메서드 호출 (JPA 변경 감지) - user.updateProfile( - request.getNickname(), - request.getBio(), - request.getProfile_photo() - ); + // 닉네임, 한 줄 소개 업데이트 + 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); } From 60aa44b92ceaf639234ede988593d54089497f48 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Fri, 14 Nov 2025 02:36:04 +0900 Subject: [PATCH 41/78] =?UTF-8?q?chore:=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/lionsforest/domain/user/User.java | 2 -- .../domain/user/controller/AuthController.java | 4 ++-- .../domain/user/controller/TempAuthController.java | 2 +- .../domain/user/controller/UserController.java | 8 ++------ .../lionsforest/domain/user/dto/LoginRequestDTO.java | 9 --------- .../domain/user/dto/request/LoginRequestDTO.java | 8 ++++++++ .../UserInfoRequestDTO.java} | 5 ++--- .../user/dto/{ => request}/UserUpdateRequestDTO.java | 2 +- .../user/dto/{ => response}/LoginResponseDTO.java | 2 +- .../user/dto/response/NicknameResponseDTO.java | 4 ++++ .../user/dto/{ => response}/TokenResponseDTO.java | 2 +- .../user/dto/{ => response}/UserInfoResponseDTO.java | 3 +-- .../dto/{ => response}/UserProfileResponseDTO.java | 2 +- .../lionsforest/domain/user/service/AuthService.java | 12 +++++------- .../lionsforest/domain/user/service/UserService.java | 5 ++--- .../global/component/FirebaseTokenVerifier.java | 6 +++--- .../global/component/GoogleTokenVerifier.java | 6 +++--- .../lionsforest/global/jwt/JwtTokenProvider.java | 2 +- 18 files changed, 38 insertions(+), 46 deletions(-) delete mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/LoginRequestDTO.java create mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/request/LoginRequestDTO.java rename src/main/java/com/example/lionsforest/domain/user/dto/{UserInfoDTO.java => request/UserInfoRequestDTO.java} (80%) rename src/main/java/com/example/lionsforest/domain/user/dto/{ => request}/UserUpdateRequestDTO.java (84%) rename src/main/java/com/example/lionsforest/domain/user/dto/{ => response}/LoginResponseDTO.java (83%) create mode 100644 src/main/java/com/example/lionsforest/domain/user/dto/response/NicknameResponseDTO.java rename src/main/java/com/example/lionsforest/domain/user/dto/{ => response}/TokenResponseDTO.java (76%) rename src/main/java/com/example/lionsforest/domain/user/dto/{ => response}/UserInfoResponseDTO.java (87%) rename src/main/java/com/example/lionsforest/domain/user/dto/{ => response}/UserProfileResponseDTO.java (91%) diff --git a/src/main/java/com/example/lionsforest/domain/user/User.java b/src/main/java/com/example/lionsforest/domain/user/User.java index e905f3c..44a1b98 100644 --- a/src/main/java/com/example/lionsforest/domain/user/User.java +++ b/src/main/java/com/example/lionsforest/domain/user/User.java @@ -5,11 +5,9 @@ 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.domain.user.dto.UserInfoResponseDTO; import com.example.lionsforest.global.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; -import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.HashSet; 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 index 20b1d95..6cc0409 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java @@ -1,7 +1,7 @@ package com.example.lionsforest.domain.user.controller; -import com.example.lionsforest.domain.user.dto.LoginRequestDTO; -import com.example.lionsforest.domain.user.dto.LoginResponseDTO; +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; 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 index c0aeee0..feb3a06 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java @@ -1,7 +1,7 @@ package com.example.lionsforest.domain.user.controller; import com.example.lionsforest.domain.user.User; -import com.example.lionsforest.domain.user.dto.TokenResponseDTO; +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; 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 index 0f339d5..06f0550 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java @@ -1,8 +1,8 @@ package com.example.lionsforest.domain.user.controller; -import com.example.lionsforest.domain.user.dto.UserInfoResponseDTO; -import com.example.lionsforest.domain.user.dto.UserUpdateRequestDTO; +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.UserService; import com.example.lionsforest.global.config.PrincipalHandler; import io.swagger.v3.oas.annotations.Operation; @@ -10,13 +10,9 @@ 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.apache.coyote.Response; -import org.springdoc.core.annotations.ParameterObject; 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; diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/LoginRequestDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/LoginRequestDTO.java deleted file mode 100644 index 3df7f6c..0000000 --- a/src/main/java/com/example/lionsforest/domain/user/dto/LoginRequestDTO.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.lionsforest.domain.user.dto; - -import com.example.lionsforest.domain.user.User; -import lombok.Getter; - -@Getter -public class LoginRequestDTO { - private String idToken; -} \ 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..d57875e --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/request/LoginRequestDTO.java @@ -0,0 +1,8 @@ +package com.example.lionsforest.domain.user.dto.request; + +import lombok.Getter; + +@Getter +public class LoginRequestDTO { + private String idToken; +} \ No newline at end of file diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/UserInfoDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/request/UserInfoRequestDTO.java similarity index 80% rename from src/main/java/com/example/lionsforest/domain/user/dto/UserInfoDTO.java rename to src/main/java/com/example/lionsforest/domain/user/dto/request/UserInfoRequestDTO.java index cb1af75..d114739 100644 --- a/src/main/java/com/example/lionsforest/domain/user/dto/UserInfoDTO.java +++ b/src/main/java/com/example/lionsforest/domain/user/dto/request/UserInfoRequestDTO.java @@ -1,7 +1,6 @@ -package com.example.lionsforest.domain.user.dto; +package com.example.lionsforest.domain.user.dto.request; import com.example.lionsforest.domain.user.User; -import com.fasterxml.jackson.annotation.JsonSubTypes; import lombok.Builder; import lombok.Getter; @@ -9,7 +8,7 @@ // GoogleTokenVerifier가 반환하는 내부 전용 DTO @Getter @Builder -public class UserInfoDTO { +public class UserInfoRequestDTO { private String name; private String email; private String profile_photo; diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/request/UserUpdateRequestDTO.java similarity index 84% rename from src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java rename to src/main/java/com/example/lionsforest/domain/user/dto/request/UserUpdateRequestDTO.java index 4e097be..d1b21bf 100644 --- a/src/main/java/com/example/lionsforest/domain/user/dto/UserUpdateRequestDTO.java +++ b/src/main/java/com/example/lionsforest/domain/user/dto/request/UserUpdateRequestDTO.java @@ -1,4 +1,4 @@ -package com.example.lionsforest.domain.user.dto; +package com.example.lionsforest.domain.user.dto.request; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/LoginResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/LoginResponseDTO.java similarity index 83% rename from src/main/java/com/example/lionsforest/domain/user/dto/LoginResponseDTO.java rename to src/main/java/com/example/lionsforest/domain/user/dto/response/LoginResponseDTO.java index c5502e3..0ff1fee 100644 --- a/src/main/java/com/example/lionsforest/domain/user/dto/LoginResponseDTO.java +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/LoginResponseDTO.java @@ -1,4 +1,4 @@ -package com.example.lionsforest.domain.user.dto; +package com.example.lionsforest.domain.user.dto.response; import lombok.Builder; import lombok.Getter; 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..7bfc8e6 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/NicknameResponseDTO.java @@ -0,0 +1,4 @@ +package com.example.lionsforest.domain.user.dto.response; + +public class NicknameResponseDTO { +} diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/TokenResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/TokenResponseDTO.java similarity index 76% rename from src/main/java/com/example/lionsforest/domain/user/dto/TokenResponseDTO.java rename to src/main/java/com/example/lionsforest/domain/user/dto/response/TokenResponseDTO.java index 880a6b9..b00fab3 100644 --- a/src/main/java/com/example/lionsforest/domain/user/dto/TokenResponseDTO.java +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/TokenResponseDTO.java @@ -1,4 +1,4 @@ -package com.example.lionsforest.domain.user.dto; +package com.example.lionsforest.domain.user.dto.response; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/UserInfoResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/UserInfoResponseDTO.java similarity index 87% rename from src/main/java/com/example/lionsforest/domain/user/dto/UserInfoResponseDTO.java rename to src/main/java/com/example/lionsforest/domain/user/dto/response/UserInfoResponseDTO.java index 78e91b9..ace9b6c 100644 --- a/src/main/java/com/example/lionsforest/domain/user/dto/UserInfoResponseDTO.java +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/UserInfoResponseDTO.java @@ -1,7 +1,6 @@ -package com.example.lionsforest.domain.user.dto; +package com.example.lionsforest.domain.user.dto.response; import com.example.lionsforest.domain.user.User; -import com.nimbusds.openid.connect.sdk.claims.UserInfo; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/example/lionsforest/domain/user/dto/UserProfileResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/UserProfileResponseDTO.java similarity index 91% rename from src/main/java/com/example/lionsforest/domain/user/dto/UserProfileResponseDTO.java rename to src/main/java/com/example/lionsforest/domain/user/dto/response/UserProfileResponseDTO.java index e2b5d79..f4d731d 100644 --- a/src/main/java/com/example/lionsforest/domain/user/dto/UserProfileResponseDTO.java +++ b/src/main/java/com/example/lionsforest/domain/user/dto/response/UserProfileResponseDTO.java @@ -1,4 +1,4 @@ -package com.example.lionsforest.domain.user.dto; +package com.example.lionsforest.domain.user.dto.response; import com.example.lionsforest.domain.user.User; import lombok.Builder; 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 index 65180aa..7ea5261 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java @@ -1,16 +1,14 @@ package com.example.lionsforest.domain.user.service; import com.example.lionsforest.domain.user.User; -import com.example.lionsforest.domain.user.dto.LoginRequestDTO; -import com.example.lionsforest.domain.user.dto.LoginResponseDTO; -import com.example.lionsforest.domain.user.dto.TokenResponseDTO; -import com.example.lionsforest.domain.user.dto.UserInfoDTO; +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.GoogleTokenVerifier; import com.example.lionsforest.global.component.MemberWhitelistValidator; import com.example.lionsforest.global.jwt.JwtTokenProvider; -import com.google.firebase.auth.FirebaseAuth; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -35,7 +33,7 @@ public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { //request DTO에서 idToken 꺼내기 String idToken = request.getIdToken(); //firebasetokenverifier가 토큰 검증 -> 사용자 정보 추출 - UserInfoDTO userInfo; + UserInfoRequestDTO userInfo; try{ userInfo = firebaseTokenVerifier.verifyIdToken(idToken); }catch(AuthenticationException e){ 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 index d0253a4..85c1094 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/UserService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/UserService.java @@ -1,13 +1,12 @@ package com.example.lionsforest.domain.user.service; import com.example.lionsforest.domain.user.User; -import com.example.lionsforest.domain.user.dto.UserInfoResponseDTO; -import com.example.lionsforest.domain.user.dto.UserUpdateRequestDTO; +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 com.nimbusds.openid.connect.sdk.UserInfoResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/example/lionsforest/global/component/FirebaseTokenVerifier.java b/src/main/java/com/example/lionsforest/global/component/FirebaseTokenVerifier.java index f9415b8..7a99044 100644 --- a/src/main/java/com/example/lionsforest/global/component/FirebaseTokenVerifier.java +++ b/src/main/java/com/example/lionsforest/global/component/FirebaseTokenVerifier.java @@ -1,6 +1,6 @@ package com.example.lionsforest.global.component; -import com.example.lionsforest.domain.user.dto.UserInfoDTO; +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; @@ -10,7 +10,7 @@ @Component public class FirebaseTokenVerifier { - public UserInfoDTO verifyIdToken(String firebaseIdToken) throws AuthenticationException { + public UserInfoRequestDTO verifyIdToken(String firebaseIdToken) throws AuthenticationException { try { //1. Firebase Admin SDK로 토큰 검증 FirebaseToken decodedToken = FirebaseAuth.getInstance().verifyIdToken(firebaseIdToken); @@ -22,7 +22,7 @@ public UserInfoDTO verifyIdToken(String firebaseIdToken) throws AuthenticationEx String uid = decodedToken.getUid(); //firebase 고유 uid //3. AuthService가 사용하던 UserInfoDTO로 변환 - return UserInfoDTO.builder() + return UserInfoRequestDTO.builder() .name(name) .email(email) .profile_photo(profilePhoto) diff --git a/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java b/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java index 9cebbb5..39e3444 100644 --- a/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java +++ b/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java @@ -1,6 +1,6 @@ package com.example.lionsforest.global.component; -import com.example.lionsforest.domain.user.dto.UserInfoDTO; +import com.example.lionsforest.domain.user.dto.request.UserInfoRequestDTO; 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; @@ -26,7 +26,7 @@ public GoogleTokenVerifier(@Value("${google.auth.client-id}") String clientId) { .build(); } - public UserInfoDTO verify(String idToken) { + public UserInfoRequestDTO verify(String idToken) { try { if (clientId == null || clientId.isBlank() || clientId.contains("YOUR_GOOGLE_CLIENT_ID")) { throw new IllegalArgumentException("Google Client ID가 application.yml에 설정되지 않았습니다."); @@ -46,7 +46,7 @@ public UserInfoDTO verify(String idToken) { throw new SecurityException("구글 토큰에서 이메일 또는 이름 정보를 가져올 수 없습니다."); } - return UserInfoDTO.builder() + return UserInfoRequestDTO.builder() .name(name) .email(email) .profile_photo(profile_photo) diff --git a/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java b/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java index 43569f9..e7fa841 100644 --- a/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java +++ b/src/main/java/com/example/lionsforest/global/jwt/JwtTokenProvider.java @@ -1,6 +1,6 @@ package com.example.lionsforest.global.jwt; -import com.example.lionsforest.domain.user.dto.TokenResponseDTO; +import com.example.lionsforest.domain.user.dto.response.TokenResponseDTO; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; From 6d9ecc370883d309cf4a984c88a41d09ece7923a Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Fri, 14 Nov 2025 05:00:55 +0900 Subject: [PATCH 42/78] =?UTF-8?q?feat:=20=EB=9E=9C=EB=8D=A4=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 12 +++ .../dto/response/NicknameResponseDTO.java | 6 ++ .../domain/user/service/AuthService.java | 3 + .../domain/user/service/NicknameService.java | 91 +++++++++++++++++++ .../domain/user/service/UserService.java | 7 ++ .../global/exception/ErrorCode.java | 5 +- src/main/resources/nickname-components.json | 62 +++++++++++++ 7 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/lionsforest/domain/user/service/NicknameService.java create mode 100644 src/main/resources/nickname-components.json 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 index 06f0550..0692754 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java @@ -1,8 +1,10 @@ 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; @@ -24,6 +26,7 @@ public class UserController { private final UserService userService; + private final NicknameService nicknameService; //유저 목록 조회 @GetMapping @@ -64,4 +67,13 @@ public ResponseEntity updateUser( 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/response/NicknameResponseDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/response/NicknameResponseDTO.java index 7bfc8e6..eb420f0 100644 --- 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 @@ -1,4 +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/service/AuthService.java b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java index 7ea5261..76a86e7 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java @@ -27,6 +27,7 @@ public class AuthService { private final MemberWhitelistValidator whitelistValidator; private final JwtTokenProvider jwtTokenProvider; private final FirebaseTokenVerifier firebaseTokenVerifier; + private final NicknameService nicknameService; public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { @@ -55,6 +56,8 @@ public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { if (optionalUser.isEmpty()) { user = userInfo.toEntity(); + String userNickname = nicknameService.generateRandomNickname(null); + user.setNickname(userNickname); userRepository.save(user); isNewUser = true; System.out.println("새 유저 생성!"); 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 index 85c1094..7b5eea2 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/UserService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/UserService.java @@ -1,6 +1,7 @@ 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; @@ -8,10 +9,16 @@ 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; diff --git a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java index e3b7752..dc23ef8 100644 --- a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -20,7 +20,10 @@ public enum ErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "존재하지 않는 유저입니다."), // 409 CONFLICT - NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "NICKNAME_ALREADY_EXISTS", "이미 사용 중인 닉네임입니다."); + NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "NICKNAME_ALREADY_EXISTS", "이미 사용 중인 닉네임입니다."), + + // 닉네임 생성 실패 + NICKNAME_GENERATION_FAILED(HttpStatus.NO_CONTENT, "NICKNAME_GENERATION_FAILED", "새로운 닉네임 생성에 실패했습니다."); private final HttpStatus status; 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 From 09d5183f99891e75372ea737a7f480d108caa227 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Fri, 14 Nov 2025 21:31:17 +0900 Subject: [PATCH 43/78] =?UTF-8?q?fix:=20=EB=82=B4=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EC=88=98=EC=A0=95=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/controller/UserController.java | 3 ++- .../user/dto/request/UserUpdateRequestDTO.java | 12 ++++++++++++ .../lionsforest/global/config/SecurityConfig.java | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) 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 index 0692754..ab34c4a 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/UserController.java @@ -12,6 +12,7 @@ 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; @@ -61,7 +62,7 @@ public ResponseEntity getMyInfo() { ) ) public ResponseEntity updateUser( - @ModelAttribute UserUpdateRequestDTO request) { + @ModelAttribute @Valid UserUpdateRequestDTO request) { Long authenticatedUserId = PrincipalHandler.getUserId(); UserInfoResponseDTO updatedUser = userService.updateUserInfo(authenticatedUserId, request); 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 index d1b21bf..a488327 100644 --- 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 @@ -1,5 +1,8 @@ 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; @@ -8,8 +11,17 @@ @Getter @Setter public class UserUpdateRequestDTO { + @NotBlank + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "새로운_닉네임") private String nickname; + + @NotBlank + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "한 줄 소개") private String bio; + private MultipartFile photo; + + @NotNull + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private Boolean removePhoto; } diff --git a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java index f776241..0954eb1 100644 --- a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -76,7 +76,7 @@ public CorsConfigurationSource corsConfigurationSource() { "https://api.lions-forest.p-e.kr", "http://localhost:8080" )); - config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); //허용할 http 메서드 + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); //허용할 http 메서드 config.setAllowedHeaders(Arrays.asList("*")); //모든 http 헤더 허용 config.setAllowCredentials(true); // 자격 증명(쿠키, authorization 헤더) 허용 config.setMaxAge(3600L); //요청 캐시 시간: 1시간 From a647dd19d666ed77bdbf74afa65d6b96983b5757 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Fri, 14 Nov 2025 21:49:50 +0900 Subject: [PATCH 44/78] =?UTF-8?q?fix:=20notblank=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/dto/request/UserUpdateRequestDTO.java | 3 --- 1 file changed, 3 deletions(-) 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 index a488327..ed4fd3d 100644 --- 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 @@ -11,17 +11,14 @@ @Getter @Setter public class UserUpdateRequestDTO { - @NotBlank @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "새로운_닉네임") private String nickname; - @NotBlank @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "한 줄 소개") private String bio; private MultipartFile photo; - @NotNull @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private Boolean removePhoto; } From 07a41608e2a32e099beade41e2d12a446883d9a8 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Fri, 14 Nov 2025 22:23:34 +0900 Subject: [PATCH 45/78] =?UTF-8?q?feat:=ED=9B=84=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/review/controller/ReviewController.java | 7 +++++++ .../domain/review/service/ReviewService.java | 10 ++++++++++ 2 files changed, 17 insertions(+) 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 index 8612a65..ca07b7a 100644 --- a/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java +++ b/src/main/java/com/example/lionsforest/domain/review/controller/ReviewController.java @@ -107,6 +107,13 @@ public ResponseEntity getReviewById(@PathVariable("review_ } + // 후기 전체 조회 + @GetMapping + @Operation(summary = "후기 전체 조회", description = "전체 후기를 조회합니다") + public ResponseEntity> getAllReview(){ + return ResponseEntity.ok(reviewService.getAllReview()); + } + // 모임별 후기 조회 @GetMapping("by-group/{group_id}/") @Operation(summary = "모임별 후기 조회", description = "특정 모임(By group_id)에 대한 후기를 조회합니다") 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 index 0348c48..fbc08f7 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -215,6 +215,16 @@ public ReviewGetResponseDto getReviewById(Long reviewId){ return ReviewGetResponseDto.fromEntity(review); } + // 후기 전체 조회 + @Transactional(readOnly = true) + public List getAllReview(){ + List reviews = reviewRepository.findAll(); + + return reviews.stream() + .map(ReviewGetResponseDto::fromEntity) + .toList(); + } + // 모임별 후기 전체 조회 @Transactional(readOnly = true) From b8015b9d5365b325ac05caf1130cd23d420bca8f Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Fri, 14 Nov 2025 22:55:00 +0900 Subject: [PATCH 46/78] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=97=AC=EB=B6=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=98=EB=8A=94=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/domain/comment/Comment.java | 2 +- .../comment/controller/CommentController.java | 13 +++++++++++++ .../dto/response/CommentLikeResponseDTO.java | 17 +++++++++++++++++ .../dto/response/CommentResponseDto.java | 2 +- .../comment/repository/CommentRepository.java | 2 ++ .../domain/comment/service/CommentService.java | 14 ++++++++++++++ .../lionsforest/global/exception/ErrorCode.java | 4 +++- 7 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/domain/comment/dto/response/CommentLikeResponseDTO.java diff --git a/src/main/java/com/example/lionsforest/domain/comment/Comment.java b/src/main/java/com/example/lionsforest/domain/comment/Comment.java index 5ed17d9..a0b7963 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/Comment.java +++ b/src/main/java/com/example/lionsforest/domain/comment/Comment.java @@ -36,5 +36,5 @@ public class Comment extends BaseTimeEntity { //이 댓글을 좋아요한 유저 @Builder.Default @ManyToMany(mappedBy = "liked_comments") - private Set liked_by_users = new HashSet<>(); + 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 index 087a8f2..9cc9ae6 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java +++ b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java @@ -1,8 +1,10 @@ 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; @@ -79,4 +81,15 @@ public ResponseEntity> getUser(@PathVariable Long group return ResponseEntity.ok(commentService.getCommentsByGroupId(groupId)); } */ + + //댓글 좋아요 눌렀는지 확인 + @GetMapping("{comment_id}/liked") + @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/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 index 028e3ff..a3e74d4 100644 --- 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 @@ -30,7 +30,7 @@ public static CommentResponseDto fromEntity(Comment comment){ .userName(comment.getUser().getName()) .userNickName(comment.getUser().getNickname()) .content(comment.getContent()) - .likeCount(comment.getLiked_by_users().size()) // 좋아요 수 + .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 index b6c0102..f60618f 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java +++ b/src/main/java/com/example/lionsforest/domain/comment/repository/CommentRepository.java @@ -11,4 +11,6 @@ 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 index 5fb5e97..970e229 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -2,6 +2,7 @@ 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; @@ -14,6 +15,8 @@ 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; @@ -155,4 +158,15 @@ public List getCommentsByUserId(Long userId){ } */ + //좋아요 눌렀는지 확인 + 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/global/exception/ErrorCode.java b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java index dc23ef8..dd24599 100644 --- a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -23,8 +23,10 @@ public enum ErrorCode { NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "NICKNAME_ALREADY_EXISTS", "이미 사용 중인 닉네임입니다."), // 닉네임 생성 실패 - NICKNAME_GENERATION_FAILED(HttpStatus.NO_CONTENT, "NICKNAME_GENERATION_FAILED", "새로운 닉네임 생성에 실패했습니다."); + NICKNAME_GENERATION_FAILED(HttpStatus.NO_CONTENT, "NICKNAME_GENERATION_FAILED", "새로운 닉네임 생성에 실패했습니다."), + //댓글 조회 실패 + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_NOT_FOUND", "댓글 조회에 실패했습니다"); private final HttpStatus status; private final String code; From 09ad3901185cd65e01660a5cd5451d018b52244a Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Sat, 15 Nov 2025 18:21:16 +0900 Subject: [PATCH 47/78] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=EB=A1=9C=20=EB=AA=A8=EC=9E=84=20state=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/repository/GroupRepository.java | 5 +++++ .../domain/group/service/GroupService.java | 9 +++++++++ .../group/service/MeetingScheduler.java | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 src/main/java/com/example/lionsforest/domain/group/service/MeetingScheduler.java 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 index 1291f9d..e4911ff 100644 --- a/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java +++ b/src/main/java/com/example/lionsforest/domain/group/repository/GroupRepository.java @@ -2,6 +2,7 @@ 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; @@ -16,4 +17,8 @@ public interface GroupRepository extends JpaRepository { 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/service/GroupService.java b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java index bd5fa2a..08c4013 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -258,4 +258,13 @@ public void managePhotos(Long groupId, List addPhotos, List } */ + //시간 지난 모임 state 변경 + @Transactional + public void closeExpiredMeetings(){ + int updatedCount = groupRepository.closeMeetingByTime(LocalDateTime.now()); + + if(updatedCount > 0){ + System.out.println(updatedCount + "개의 모임이 마감 처리되었습니다."); + } + } } 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(); + } +} From d07b2b74cef815052cada4f8b9c6b9dcedb33977 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Sat, 15 Nov 2025 18:21:43 +0900 Subject: [PATCH 48/78] =?UTF-8?q?fix:=20=EC=B5=9C=EC=B4=88=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=8B=9C=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/lionsforest/domain/user/service/AuthService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 76a86e7..3c76068 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java @@ -56,7 +56,7 @@ public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { if (optionalUser.isEmpty()) { user = userInfo.toEntity(); - String userNickname = nicknameService.generateRandomNickname(null); + String userNickname = nicknameService.generateRandomNickname(""); user.setNickname(userNickname); userRepository.save(user); isNewUser = true; From 4065550b95525f5a2b43bf651f8decc8eb4c4a64 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Sat, 15 Nov 2025 18:22:29 +0900 Subject: [PATCH 49/78] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=ED=99=95=EC=9D=B8=20api=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/comment/controller/CommentController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 9cc9ae6..5af0de9 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java +++ b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java @@ -83,7 +83,7 @@ public ResponseEntity> getUser(@PathVariable Long group */ //댓글 좋아요 눌렀는지 확인 - @GetMapping("{comment_id}/liked") + @GetMapping("{comment_id}/like/status") @Operation(summary = "특정 댓글 좋아요 확인", description = "해당 유저가 comment_id에 좋아요를 눌렀는지 확인합니다") public ResponseEntity viewCommentLike( @PathVariable("comment_id") Long commentId From 5adb6aac5e3e3b4225b3992778b0998028fdb8fa Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Sat, 15 Nov 2025 20:26:42 +0900 Subject: [PATCH 50/78] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EA=B0=84=EB=8B=A8=20=EC=A1=B0=ED=9A=8C=20api=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/controller/GroupController.java | 10 ++++ .../response/GroupSimpleInfoResponseDto.java | 48 +++++++++++++++++++ .../domain/group/service/GroupService.java | 11 +++++ .../global/exception/ErrorCode.java | 5 +- 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/lionsforest/domain/group/dto/response/GroupSimpleInfoResponseDto.java 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 index 68d408b..ffbb7d2 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -4,6 +4,7 @@ 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; @@ -136,4 +137,13 @@ public ResponseEntity manageGroupPhotos( 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/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/service/GroupService.java b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java index 08c4013..3657933 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -5,6 +5,7 @@ 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; @@ -17,9 +18,12 @@ 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; @@ -267,4 +271,11 @@ public void closeExpiredMeetings(){ 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/global/exception/ErrorCode.java b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java index dd24599..fe7838e 100644 --- a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -26,7 +26,10 @@ public enum ErrorCode { NICKNAME_GENERATION_FAILED(HttpStatus.NO_CONTENT, "NICKNAME_GENERATION_FAILED", "새로운 닉네임 생성에 실패했습니다."), //댓글 조회 실패 - COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_NOT_FOUND", "댓글 조회에 실패했습니다"); + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_NOT_FOUND", "댓글 조회에 실패했습니다"), + + //모임 없음 + GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "GROUP_NOT_FOUND", "모임 조회에 실패했습니다"); private final HttpStatus status; private final String code; From c2fec6307e5eda84d487495246897cd8308654a4 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Sat, 15 Nov 2025 23:00:53 +0900 Subject: [PATCH 51/78] =?UTF-8?q?fix:=20=ED=9B=84=EA=B8=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20dto=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/domain/review/dto/response/ReviewPhotoDto.java | 2 ++ 1 file changed, 2 insertions(+) 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 index b462714..dc5897f 100644 --- 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 @@ -5,10 +5,12 @@ @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(); } From 60b64b99cf4cdbaeb81cbd3455c1f9dd15cc7abf Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Sun, 16 Nov 2025 02:02:08 +0900 Subject: [PATCH 52/78] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20UR?= =?UTF-8?q?L=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/lionsforest/global/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java index 0954eb1..547291a 100644 --- a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -72,6 +72,7 @@ public CorsConfigurationSource corsConfigurationSource() { "http://localhost:3000", "http://localhost:5173", "https://lionforest-dev.netlify.app", + "https://lions-forest.vercel.app", //백엔드 주소 "https://api.lions-forest.p-e.kr", "http://localhost:8080" From 2c606bd1a40de0b4fb759fb18c42124fc1be7884 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Sun, 16 Nov 2025 19:58:31 +0900 Subject: [PATCH 53/78] =?UTF-8?q?chore:=20=EB=8C=93=EA=B8=80=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=ED=8F=AC=EB=A7=B7=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/comment/service/CommentService.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 970e229..f98f35e 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -112,6 +112,8 @@ public String toggleLike(Long commentId, Long userId){ Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다.")); + Group group = comment.getGroup(); + // @ManyToMany의 주인(User) 엔티티를 가져와야 함 User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); @@ -135,9 +137,13 @@ public String toggleLike(Long commentId, Long userId){ 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(content) .photo(photoPath) .build(); notificationRepository.save(notification); From f2dc37115f2281fcaa0f1cb8bde4b5a5a188040f Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Mon, 17 Nov 2025 11:55:32 +0900 Subject: [PATCH 54/78] =?UTF-8?q?chore:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 7 -- .../comment/service/CommentService.java | 25 ++----- .../group/controller/GroupController.java | 23 ------ .../domain/group/service/GroupService.java | 72 +++---------------- .../group/service/ParticipationService.java | 18 ++--- .../domain/review/service/ReviewService.java | 20 +++--- .../global/exception/ErrorCode.java | 30 +++++++- 7 files changed, 66 insertions(+), 129 deletions(-) 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 index 5af0de9..4c6381c 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java +++ b/src/main/java/com/example/lionsforest/domain/comment/controller/CommentController.java @@ -74,13 +74,6 @@ public ResponseEntity toggleLike( String message = commentService.toggleLike(commentId, loginUserId); return ResponseEntity.ok(message); } -/* - // 유저별 댓글 조회 - @GetMapping("/{group_id}") - public ResponseEntity> getUser(@PathVariable Long groupId){ - return ResponseEntity.ok(commentService.getCommentsByGroupId(groupId)); - } -*/ //댓글 좋아요 눌렀는지 확인 @GetMapping("{comment_id}/like/status") 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 index f98f35e..72069fa 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -40,10 +40,10 @@ public class CommentService { @Transactional public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, Long userId){ Group group = groupRepository.findById(groupId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); Comment comment = Comment.builder() .group(group) @@ -84,13 +84,13 @@ public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, Lon @Transactional public void deleteComment(Long commentId, Long userId){ Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); if (!comment.getUser().getId().equals(user.getId())) { - throw new IllegalArgumentException("댓글 작성자만 삭제할 수 있습니다."); + throw new BusinessException(ErrorCode.COMMENT_PERMISSION_DENIED); } commentRepository.delete(comment); @@ -110,13 +110,13 @@ public List getCommentsByGroupId(Long groupId){ @Transactional public String toggleLike(Long commentId, Long userId){ Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); Group group = comment.getGroup(); // @ManyToMany의 주인(User) 엔티티를 가져와야 함 User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); // User 엔티티의 liked_comments Set을 가져옴 Set likedComments = user.getLiked_comments(); @@ -152,17 +152,6 @@ public String toggleLike(Long commentId, Long userId){ return "좋아요가 추가되었습니다."; } } -/* - // 유저별 댓글 조회 - @Transactional(readOnly = true) - public List getCommentsByUserId(Long userId){ - List comments = commentRepository.findByUserId(userId); - - return comments.stream() - .map(CommentResponseDto::fromEntity) - .toList(); - } - */ //좋아요 눌렀는지 확인 public CommentLikeResponseDTO isLiked(Long commentId, Long userId){ 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 index ffbb7d2..627f2aa 100644 --- a/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java +++ b/src/main/java/com/example/lionsforest/domain/group/controller/GroupController.java @@ -114,29 +114,6 @@ public ResponseEntity deleteGroup(@PathVariable("group_id") Long groupId return ResponseEntity.ok("모임이 성공적으로 삭제되었습니다."); } - /* - // 모임 사진 일괄 수정 (추가 + 삭제) - @PostMapping(value = "/{groupId}/photos/manage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity manageGroupPhotos( - @PathVariable Long groupId, - // 새로 추가할 파일 목록 - @RequestPart(value = "addPhotos", required = false) List addPhotos, - // 삭제할 사진 ID 목록 (예: ?deletePhotoIds=1&deletePhotoIds=3) - @RequestPart(value = "deletePhotoIds", required = false) List deletePhotoIds, - @RequestPart("userId") Long userId // [임시 인증] - ) { - // [임시 인증] - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); - /* 파라미터에 @AuthenticationPrincipal UserDetailsImpl userDetails 추가 - User user = userDetails.getUser(); // 진짜 인증(JWT) - - // 서비스의 새 메서드 호출 - groupService.managePhotos(groupId, addPhotos, deletePhotoIds, user); - - return ResponseEntity.ok("사진이 성공적으로 수정되었습니다."); - } - */ //모임 정보 간단 조회 @GetMapping("{group_id}/simple") 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 index 3657933..cda9ec4 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -47,7 +47,7 @@ public class GroupService { @Transactional public GroupResponseDto createGroup(GroupRequestDto dto, Long userId){ User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); // Group Entity 먼저 생성(ID 확보) Group group = dto.toEntity(user); @@ -101,7 +101,7 @@ public List getAllGroup(){ public GroupGetResponseDto getGroupById(Long groupId) { Group group = groupRepository.findByIdWithPhotos(groupId) - .orElseThrow(() -> new IllegalArgumentException("해당 모임이 존재하지 않습니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); return GroupGetResponseDto.fromEntity(group); } @@ -120,15 +120,15 @@ public GroupResponseDto updateGroup(Long groupId, GroupUpdateRequestDto dto, Lon // 모임 조회 Group group = groupRepository.findById(groupId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); // 유저 조회 User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); // 유저 권한 확인 if(!group.getLeader().getId().equals(user.getId())){ - throw new IllegalArgumentException("모임장만 모임을 수정할 수 있습니다."); + throw new BusinessException(ErrorCode.GROUP_PERMISSION_DENIED); } if (dto.getTitle() != null) { @@ -158,20 +158,20 @@ public GroupResponseDto updateGroup(Long groupId, GroupUpdateRequestDto dto, Lon public void deleteGroup(Long groupId, Long userId){ // 모임 조회 Group group = groupRepository.findById(groupId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); // 유저 조회 User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); // 모임 취소 시점 제한 if (group.getMeetingAt().isBefore(LocalDateTime.now())) { - throw new IllegalStateException("이미 종료된 모임은 취소할 수 없습니다."); + throw new BusinessException(ErrorCode.GROUP_CANCEL_TIME_EXCEEDED); } // 유저 권한 확인 if(!group.getLeader().getId().equals(user.getId())){ - throw new IllegalArgumentException("모임장만 모임을 삭제할 수 있습니다."); + throw new BusinessException(ErrorCode.GROUP_PERMISSION_DENIED); } // 알림 생성: 모임 참가자들에게 모임 취소 알림 보내기 @@ -208,60 +208,6 @@ public void deleteGroup(Long groupId, Long userId){ groupRepository.delete(group); } - /* - //사진 일괄 관리 (추가 + 삭제) - @Transactional - public void managePhotos(Long groupId, List addPhotos, List deletePhotoIds, User user) { - - Group group = groupRepository.findById(groupId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); - if (!group.getLeader().getId().equals(user.getId())) { - throw new IllegalArgumentException("모임장만 사진을 수정할 수 있습니다."); - } - - // 사진 갯수 제한 검증(최대 5장) - // 현재 사진 개수 - int currentPhotoCount = groupPhotoRepository.countByGroupId(groupId); // (Repository에 countByGroupId 추가 필요) - // 삭제할 개수 - int deleteCount = (deletePhotoIds != null) ? deletePhotoIds.size() : 0; - // 추가할 개수 - int addCount = (addPhotos != null) ? addPhotos.size() : 0; - // 최종 개수 확인 - if (currentPhotoCount - deleteCount + addCount > 5) { - throw new IllegalArgumentException("사진은 최대 5개까지만 업로드할 수 있습니다."); - } - - // 사진 삭제 (DB + 로컬/S3 파일 삭제) - if (deletePhotoIds != null && !deletePhotoIds.isEmpty()) { - List photosToDelete = groupPhotoRepository.findAllById(deletePhotoIds); - - for (GroupPhoto photo : photosToDelete) { - if (!photo.getGroup().getId().equals(groupId)) { - throw new IllegalArgumentException("다른 모임의 사진을 삭제할 수 없습니다."); - } - s3UploadService.delete(photo.getPhoto()); - } - groupPhotoRepository.deleteAll(photosToDelete); - } - - // 사진 추가 (로컬/S3 업로드 + DB 저장) - if (addPhotos != null && !addPhotos.isEmpty()) { - // (createGroup의 사진 추가 로직과 동일) - List groupPhotos = new ArrayList<>(); - // (참고: photo_order는 이 로직에서 관리하기 매우 복잡해집니다) - for (int i = 0; i < addPhotos.size(); i++) { - String photoUrl = s3UploadService.upload(addPhotos.get(i), "group-photos"); - groupPhotos.add(GroupPhoto.builder() - .group(group) - .photo(photoUrl) - .photo_order(i + 100) // (순서 로직은 별도 정책 필요) - .build()); - } - groupPhotoRepository.saveAll(groupPhotos); - } - } - */ - //시간 지난 모임 state 변경 @Transactional public void 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 index ad7f61f..f3dc648 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -14,6 +14,8 @@ 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; @@ -35,21 +37,21 @@ public class ParticipationService { @Transactional public ParticipationResponseDto joinGroup(Long groupId, Long userId){ Group group = groupRepository.findById(groupId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); // 중복 참여 체크 if(participationRepository.existsByGroupAndUser(group, user)) { - throw new IllegalArgumentException("이미 참여 신청한 모임입니다."); + throw new BusinessException(ErrorCode.PARTICIPATION_ALREADY_EXISTS); } // 인원 제한 체크 long currentCount = participationRepository.countByGroupId(groupId); int capacity = group.getCapacity(); if(currentCount >= capacity) { - throw new IllegalArgumentException("모임 인원이 가득 찼습니다."); + throw new BusinessException(ErrorCode.GROUP_CAPACITY_FULL); } Participation participation = Participation.builder() @@ -97,18 +99,18 @@ public List getAllMyParticipations(Long userId){ @Transactional public void leaveGroup(Long groupId, Long userId) { Group group = groupRepository.findById(groupId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); if (group.getLeader().getId().equals(userId)) { - throw new IllegalArgumentException("모임장은 모임을 탈퇴할 수 없습니다."); + throw new BusinessException(ErrorCode.GROUP_LEADER_CANNOT_LEAVE); } Participation participation = participationRepository .findByGroupIdAndUserId(groupId, user.getId()) - .orElseThrow(() -> new IllegalArgumentException("참여하지 않은 모임입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_PARTICIPATION_NOT_FOUND)); participationRepository.delete(participation); 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 index fbc08f7..f72d2d6 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -20,6 +20,8 @@ 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; @@ -48,12 +50,12 @@ public ReviewResponseDto createReview(Long groupId, ReviewRequestDto dto, Long userId){ Group group = groupRepository.findById(groupId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 모임입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.GROUP_NOT_FOUND)); User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); if (!participationRepository.existsByGroupIdAndUserId(groupId, userId)) { - throw new IllegalArgumentException("참여한 모임에만 후기를 작성할 수 있습니다."); + throw new BusinessException(ErrorCode.GROUP_PARTICIPATION_NOT_FOUND); } Review review = Review.builder() @@ -116,14 +118,14 @@ public ReviewResponseDto createReview(Long groupId, @Transactional public void deleteReview(Long reviewId, Long userId){ User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); Review review = reviewRepository .findById(reviewId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 후기입니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); if(!review.getUser().getId().equals(userId)) { - throw new IllegalArgumentException("작성자만 후기를 삭제할 수 있습니다."); + throw new BusinessException(ErrorCode.REVIEW_PERMISSION_DENIED); } if (review.getPhotos() != null) { @@ -139,10 +141,10 @@ public ReviewResponseDto updateReview(Long reviewId, ReviewUpdateRequestDto dto, Long userId){ Review review = reviewRepository.findById(reviewId) - .orElseThrow(() -> new IllegalArgumentException("해당 후기가 존재하지 않습니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); if (!review.getUser().getId().equals(userId)) { - throw new IllegalArgumentException("작성자만 후기를 수정할 수 있습니다."); + throw new BusinessException(ErrorCode.REVIEW_PERMISSION_DENIED); } if (dto.getContent() != null) { @@ -211,7 +213,7 @@ public ReviewResponseDto updateReview(Long reviewId, @Transactional(readOnly = true) public ReviewGetResponseDto getReviewById(Long reviewId){ Review review = reviewRepository.findById(reviewId) - .orElseThrow(() -> new IllegalArgumentException("해당 후기가 존재하지 않습니다.")); + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); return ReviewGetResponseDto.fromEntity(review); } diff --git a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java index fe7838e..826cad9 100644 --- a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -29,7 +29,35 @@ public enum ErrorCode { COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_NOT_FOUND", "댓글 조회에 실패했습니다"), //모임 없음 - GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "GROUP_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", "모임 시작 이후에는 취소할 수 없습니다."), + + // 모임 중복 신청 제한 + 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", "후기 조회에 실패했습니다"); + private final HttpStatus status; private final String code; From edec7380efd4cd6a0a87ac5cd956c3a4020e4eef Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Mon, 17 Nov 2025 12:33:25 +0900 Subject: [PATCH 55/78] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20TargetId=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/comment/service/CommentService.java | 5 +++++ .../domain/group/service/ParticipationService.java | 3 +++ .../lionsforest/domain/notification/Notification.java | 6 ++++++ .../lionsforest/domain/notification/TargetType.java | 8 ++++++++ .../dto/response/NotificationResponseDto.java | 5 +++++ .../notification/scheduler/NotificationScheduler.java | 3 +++ .../lionsforest/domain/review/service/ReviewService.java | 3 +++ 7 files changed, 33 insertions(+) create mode 100644 src/main/java/com/example/lionsforest/domain/notification/TargetType.java 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 index 72069fa..aeea8d0 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -12,6 +12,7 @@ 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; @@ -72,6 +73,8 @@ public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, Lon .user(part.getUser()) .content(content) .photo(photoPath) + .targetId(comment.getCommentId()) + .targetType(TargetType.COMMENT) .build(); notificationRepository.save(notification); } @@ -145,6 +148,8 @@ public String toggleLike(Long commentId, Long userId){ .user(author) .content(content) .photo(photoPath) + .targetId(commentId) + .targetType(TargetType.COMMENT) .build(); notificationRepository.save(notification); } 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 index f3dc648..7c0985d 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/ParticipationService.java @@ -8,6 +8,7 @@ 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; @@ -74,6 +75,8 @@ public ParticipationResponseDto joinGroup(Long groupId, Long userId){ .user(user) // 본인에게 .content(content) .photo(photoPath) + .targetId(groupId) + .targetType(TargetType.GROUP) .build(); notificationRepository.save(notification); diff --git a/src/main/java/com/example/lionsforest/domain/notification/Notification.java b/src/main/java/com/example/lionsforest/domain/notification/Notification.java index d624d34..2fdd488 100644 --- a/src/main/java/com/example/lionsforest/domain/notification/Notification.java +++ b/src/main/java/com/example/lionsforest/domain/notification/Notification.java @@ -27,8 +27,14 @@ public class Notification extends BaseTimeEntity { 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..845994b --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/TargetType.java @@ -0,0 +1,8 @@ +package com.example.lionsforest.domain.notification; + +public enum TargetType { + GROUP, + REVIEW, + COMMENT + +} 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 index c90d4aa..ea324f5 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -13,6 +14,8 @@ public class NotificationResponseDto { private Long id; private String content; private String photo; + private Long targetId; + private TargetType targetType; private boolean read; private LocalDateTime createdAt; @@ -21,6 +24,8 @@ public static NotificationResponseDto fromEntity(Notification notification) { .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/scheduler/NotificationScheduler.java b/src/main/java/com/example/lionsforest/domain/notification/scheduler/NotificationScheduler.java index 3b5b315..f752142 100644 --- a/src/main/java/com/example/lionsforest/domain/notification/scheduler/NotificationScheduler.java +++ b/src/main/java/com/example/lionsforest/domain/notification/scheduler/NotificationScheduler.java @@ -7,6 +7,7 @@ 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; @@ -57,6 +58,8 @@ public void notifyEventsStartingSoon() { .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/review/service/ReviewService.java b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java index f72d2d6..b349420 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -7,6 +7,7 @@ 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; @@ -106,6 +107,8 @@ public ReviewResponseDto createReview(Long groupId, .user(part.getUser()) .content(content) .photo(photoPath) + .targetId(review.getId()) + .targetType(TargetType.REVIEW) .build(); notificationRepository.save(notification); } From 3bebd3fbae59e8cdd3b1f5fe975e79ed8e85d5fa Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 17 Nov 2025 14:05:42 +0900 Subject: [PATCH 56/78] =?UTF-8?q?feat:=20ci=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..17a6569 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,73 @@ +# 워크플로우의 이름 +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 build + + # ======================================================= + # 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: ${{ secrets.JAR_PATH_ON_EC2 }} # 원격(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..." + chmod +x /home/ubuntu/app/deploy.sh # (경로 주의) EC2에 있는 스크립트에 실행 권한 부여 + /home/ubuntu/app/deploy.sh # (경로 주의) EC2에 있는 스크립트 실행 \ No newline at end of file From ced02920594a94a8c6049c1dbce6f79d9b20f91f Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 17 Nov 2025 14:12:42 +0900 Subject: [PATCH 57/78] =?UTF-8?q?fix:=20ci=EC=BD=94=EB=93=9C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 17a6569..e8101cc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -69,5 +69,5 @@ jobs: key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} script: | echo "Starting deployment script..." - chmod +x /home/ubuntu/app/deploy.sh # (경로 주의) EC2에 있는 스크립트에 실행 권한 부여 - /home/ubuntu/app/deploy.sh # (경로 주의) EC2에 있는 스크립트 실행 \ No newline at end of file + chmod +x /home/ubuntu/server/deploy.sh # (경로 주의) EC2에 있는 스크립트에 실행 권한 부여 + /home/ubuntu/server/deploy.sh # (경로 주의) EC2에 있는 스크립트 실행 \ No newline at end of file From 7dcfb9c8c4fd1a3fa5f45b4bbc8b9f4434a490dd Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 17 Nov 2025 16:10:45 +0900 Subject: [PATCH 58/78] =?UTF-8?q?fix:=20oauth=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/response/LoginResponseDTO.java | 1 + .../domain/user/service/AuthService.java | 24 +++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) 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 index 0ff1fee..6200b4d 100644 --- 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 @@ -12,4 +12,5 @@ public class LoginResponseDTO { private String refreshToken; private boolean isNewUser; //최초 가입 여부 private String nickname; + private String firebaseToken; } 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 index 3c76068..037661b 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java @@ -7,8 +7,11 @@ 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.GoogleTokenVerifier; import com.example.lionsforest.global.component.MemberWhitelistValidator; 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.stereotype.Service; @@ -27,6 +30,7 @@ public class AuthService { private final MemberWhitelistValidator whitelistValidator; private final JwtTokenProvider jwtTokenProvider; private final FirebaseTokenVerifier firebaseTokenVerifier; + private final GoogleTokenVerifier googleTokenVerifier; private final NicknameService nicknameService; public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { @@ -34,13 +38,7 @@ public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { //request DTO에서 idToken 꺼내기 String idToken = request.getIdToken(); //firebasetokenverifier가 토큰 검증 -> 사용자 정보 추출 - UserInfoRequestDTO userInfo; - try{ - userInfo = firebaseTokenVerifier.verifyIdToken(idToken); - }catch(AuthenticationException e){ - log.error("Invalid ID Token: {}", e.getMessage()); - throw new SecurityException("유효하지 않은 토큰입니다.", e); - } + UserInfoRequestDTO userInfo = googleTokenVerifier.verify(idToken); String name = userInfo.getName(); String email = userInfo.getEmail(); @@ -66,6 +64,17 @@ public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { System.out.println("이미 존재하는 유저"); } + // 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 RuntimeException("Firebase 토큰 생성 중 오류가 발생했습니다.", e); + } + // JWT 토큰 생성 (반환 타입이 TokenResponse DTO임) TokenResponseDTO tokens = jwtTokenProvider.createTokens(user.getId(), user.getEmail()); @@ -76,6 +85,7 @@ public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { .refreshToken(tokens.getRefreshToken()) // TokenResponse DTO의 getter .isNewUser(isNewUser) .nickname(user.getNickname()) + .firebaseToken(firebaseToken) .build(); } } \ No newline at end of file From e379cf21594e2de56055e25b011679adeb804280 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 17 Nov 2025 16:32:56 +0900 Subject: [PATCH 59/78] =?UTF-8?q?fix:=20CI=20build=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20H2=20db=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 2b718b6..f51d83c 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,8 @@ dependencies { 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') { From ddf6f2b7c2a03051e9dfd3f3ffa9c774d20bb98b Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 17 Nov 2025 16:35:57 +0900 Subject: [PATCH 60/78] =?UTF-8?q?feat:=20test/application.yml=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- src/test/resources/application.yml | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 src/test/resources/application.yml diff --git a/.gitignore b/.gitignore index a15b950..ab86540 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ out/ application.yml src/main/resources/application-secret.yml src/main/resources/members.txt -src/main/resources/firebase-service-account.json \ No newline at end of file +src/main/resources/firebase-service-account.json +!src/test/resources/application.yml \ 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..51c450c --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,17 @@ +# 테스트 시에만 적용될 설정 (H2 인메모리 DB 사용) + +spring: + # DB 설정을 H2로 덮어쓰기 + datasource: + url: jdbc:h2:mem:testdb # 인메모리 H2 DB 사용 + driverClassName: org.h2.Driver + username: sa + password: + + # JPA 설정을 H2용으로 덮어쓰기 + jpa: + hibernate: + ddl-auto: create-drop # 테스트 시작 시 테이블 생성, 끝나면 삭제 + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect # H2 방언(Dialect) 사용 \ No newline at end of file From 9906caa7db789e38ae434bc292cfbbe54444aa83 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 17 Nov 2025 16:49:15 +0900 Subject: [PATCH 61/78] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application.yml | 32 +++++++++++++++---- .../resources/firebase-service-account.json | 1 + src/test/resources/members.txt | 0 src/test/resources/nickname-components.json | 1 + 4 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 src/test/resources/firebase-service-account.json create mode 100644 src/test/resources/members.txt create mode 100644 src/test/resources/nickname-components.json diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 51c450c..4e86d28 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,17 +1,37 @@ -# 테스트 시에만 적용될 설정 (H2 인메모리 DB 사용) +# 테스트 시에만 적용될 설정 spring: - # DB 설정을 H2로 덮어쓰기 + # (1) main의 'import' 구문을 덮어씁니다. + # 이것으로 존재하지 않는 application-secret.yml을 찾지 않도록 합니다. + config: + import: "" # 빈 값으로 덮어쓰기 + + # (2) 프로필을 'test'로 덮어쓰기 + profiles: + active: test + + # (3) DB 설정을 H2로 덮어쓰기 datasource: - url: jdbc:h2:mem:testdb # 인메모리 H2 DB 사용 + url: jdbc:h2:mem:testdb driverClassName: org.h2.Driver username: sa password: - # JPA 설정을 H2용으로 덮어쓰기 + # (4) JPA 설정을 H2용으로 덮어쓰기 jpa: hibernate: - ddl-auto: create-drop # 테스트 시작 시 테이블 생성, 끝나면 삭제 + ddl-auto: create-drop properties: hibernate: - dialect: org.hibernate.dialect.H2Dialect # H2 방언(Dialect) 사용 \ No newline at end of file + 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 From 6e35e2cebc590fcd0ec7f5b060e8e92082cc0e22 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 17 Nov 2025 16:55:54 +0900 Subject: [PATCH 62/78] =?UTF-8?q?fix:=20ci=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=EC=97=90=EC=84=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=97=86=EC=95=A0=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e8101cc..65b2667 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,7 +43,7 @@ jobs: # 5. Gradle로 빌드 (테스트 스킵을 원하면 -x test 추가) - name: Build with Gradle - run: ./gradlew build + run: ./gradlew build -x test # ======================================================= # 6. (CD 시작) 빌드된 JAR 파일을 EC2로 전송 From 6e056d9fbba80b6e27ce5aedeb06e4b78f66de89 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Mon, 17 Nov 2025 17:02:25 +0900 Subject: [PATCH 63/78] =?UTF-8?q?fix:=20=EC=8B=A4=ED=96=89=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 65b2667..010e034 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -69,5 +69,4 @@ jobs: key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} script: | echo "Starting deployment script..." - chmod +x /home/ubuntu/server/deploy.sh # (경로 주의) EC2에 있는 스크립트에 실행 권한 부여 /home/ubuntu/server/deploy.sh # (경로 주의) EC2에 있는 스크립트 실행 \ No newline at end of file From 9ae4f04bc49cd853cd15301fb9f06eaaa930ad68 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 01:00:04 +0900 Subject: [PATCH 64/78] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=EA=B8=80=20=EC=BD=94=EB=93=9C=20=EB=B0=9B=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- .../user/controller/AuthController.java | 2 +- .../user/dto/request/LoginRequestDTO.java | 3 +- .../domain/user/service/AuthService.java | 26 ++++-- .../global/component/GoogleOAuthService.java | 92 +++++++++++++++++++ 5 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/example/lionsforest/global/component/GoogleOAuthService.java diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 010e034..88870af 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,7 +43,7 @@ jobs: # 5. Gradle로 빌드 (테스트 스킵을 원하면 -x test 추가) - name: Build with Gradle - run: ./gradlew build -x test + run: ./gradlew bootJar -x test # ======================================================= # 6. (CD 시작) 빌드된 JAR 파일을 EC2로 전송 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 index 6cc0409..da143f8 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/AuthController.java @@ -27,7 +27,7 @@ public class AuthController { public ResponseEntity googleLogin( @Valid @RequestBody LoginRequestDTO request) { - LoginResponseDTO response = authService.googleLoginOrRegister(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/dto/request/LoginRequestDTO.java b/src/main/java/com/example/lionsforest/domain/user/dto/request/LoginRequestDTO.java index d57875e..964a9d9 100644 --- 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 @@ -4,5 +4,6 @@ @Getter public class LoginRequestDTO { - private String idToken; + private String code; + private String redirectUri; } \ No newline at end of file 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 index 037661b..f70ac73 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java @@ -7,6 +7,7 @@ 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.jwt.JwtTokenProvider; @@ -14,6 +15,7 @@ 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; @@ -29,16 +31,28 @@ public class AuthService { private final UserRepository userRepository; private final MemberWhitelistValidator whitelistValidator; private final JwtTokenProvider jwtTokenProvider; - private final FirebaseTokenVerifier firebaseTokenVerifier; private final GoogleTokenVerifier googleTokenVerifier; private final NicknameService nicknameService; + private final GoogleOAuthService googleOAuthService; - public LoginResponseDTO googleLoginOrRegister(LoginRequestDTO request) { + @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) { - //request DTO에서 idToken 꺼내기 - String idToken = request.getIdToken(); - //firebasetokenverifier가 토큰 검증 -> 사용자 정보 추출 - UserInfoRequestDTO userInfo = googleTokenVerifier.verify(idToken); String name = userInfo.getName(); String email = userInfo.getEmail(); 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..73a6e1b --- /dev/null +++ b/src/main/java/com/example/lionsforest/global/component/GoogleOAuthService.java @@ -0,0 +1,92 @@ +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.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 SecurityException("Google ID Token의 Client ID가 유효하지 않습니다."); + } + 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 SecurityException("Google 토큰에서 이메일 또는 이름 정보를 가져올 수 없습니다."); + } + + return UserInfoRequestDTO.builder() + .name(name) + .email(email) + .profile_photo(profilePhoto) + .build(); + } catch(IOException e){ + log.error("Google 인증 코드 교환 또는 토큰 파싱 실패: {}", e.getMessage(), e); + throw new RuntimeException("Google 인증 중 오류가 발생했습니다.", e); + } + } + + //구글에 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(); + } +} From d81746c1467846af957fcff54a95e434f819247a Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 02:20:21 +0900 Subject: [PATCH 65/78] =?UTF-8?q?chore:=20-plain.jar=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=95=88=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.gradle b/build.gradle index f51d83c..ce5f517 100644 --- a/build.gradle +++ b/build.gradle @@ -54,3 +54,7 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +jar{ + enabled = false +} \ No newline at end of file From b8febbb357579d212ea0a285a2f99e39bd3a4f0e Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 02:29:52 +0900 Subject: [PATCH 66/78] =?UTF-8?q?chore:=20=EB=94=94=EB=B2=84=EA=B9=85=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 88870af..c106183 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,14 @@ jobs: 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 From 41f295cef01d2ecd67234a7d012589cc6237413f Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 03:13:38 +0900 Subject: [PATCH 67/78] =?UTF-8?q?chore:=20jar=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- .../controller/NotificationController.java | 53 ------------------- 2 files changed, 1 insertion(+), 54 deletions(-) delete mode 100644 src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c106183..e0c5a99 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -64,7 +64,7 @@ jobs: key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} # (필수) GitHub Secret에서 가져옴 port: 22 # SSH 포트 (기본 22) source: "build/libs/*.jar" # 로컬(GitHub Runner)의 .jar 파일 위치 - target: ${{ secrets.JAR_PATH_ON_EC2 }} # 원격(EC2) 서버에 저장될 경로 및 이름 + target: "/home/ubuntu/server/jars" # 원격(EC2) 서버에 저장될 경로 및 이름 # ======================================================= # 7. (CD 완료) EC2에 접속하여 배포 스크립트 실행 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 deleted file mode 100644 index c55f111..0000000 --- a/src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java +++ /dev/null @@ -1,53 +0,0 @@ -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 io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -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; - - // 알림 목록 조회 - @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); - } -} - From a25c5cd785838321eaacd85ce53e3f2cbb5d12ee Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 03:14:28 +0900 Subject: [PATCH 68/78] =?UTF-8?q?chore:=20=EC=82=AD=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/NotificationController.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java 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..9dcc533 --- /dev/null +++ b/src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java @@ -0,0 +1,54 @@ +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +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; + + // 알림 목록 조회 + @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); + } + +} + From 602282b0274d849e88862a76c56b3408b50a1892 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 09:59:12 +0900 Subject: [PATCH 69/78] =?UTF-8?q?feat:=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/controller/TempAuthController.java | 5 +++++ 1 file changed, 5 insertions(+) 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 index feb3a06..cfc4722 100644 --- a/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java +++ b/src/main/java/com/example/lionsforest/domain/user/controller/TempAuthController.java @@ -50,4 +50,9 @@ public ResponseEntity getTestToken( // 3. 토큰을 JSON 응답으로 반환 return ResponseEntity.ok(tokens); } + + @GetMapping("/api/health") + public String health() { + return "OK"; + } } From 043a0c236d52b7cef5de212673e8a3c9e969aafb Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 10:46:53 +0900 Subject: [PATCH 70/78] =?UTF-8?q?feat:=20=EC=A7=80=EB=8F=84=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notification/TargetType.java | 3 +- .../controller/NotificationController.java | 14 ++++++ .../service/NotificationService.java | 49 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/lionsforest/domain/notification/service/NotificationService.java diff --git a/src/main/java/com/example/lionsforest/domain/notification/TargetType.java b/src/main/java/com/example/lionsforest/domain/notification/TargetType.java index 845994b..b635e04 100644 --- a/src/main/java/com/example/lionsforest/domain/notification/TargetType.java +++ b/src/main/java/com/example/lionsforest/domain/notification/TargetType.java @@ -3,6 +3,7 @@ public enum TargetType { GROUP, REVIEW, - COMMENT + 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 index 9dcc533..b10ac0d 100644 --- a/src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/example/lionsforest/domain/notification/controller/NotificationController.java @@ -3,9 +3,13 @@ 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; @@ -17,6 +21,7 @@ @Tag(name = "알림", description = "알림 관련 API") public class NotificationController { private final NotificationRepository notificationRepository; + private final NotificationService notificationService; // 알림 목록 조회 @GetMapping("{user_id}/") @@ -50,5 +55,14 @@ public void markAsRead(@PathVariable(value = "notification_id") Long notificatio 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/service/NotificationService.java b/src/main/java/com/example/lionsforest/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..3e5edf9 --- /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.GROUP) + .build(); + notificationRepository.save(notification); + + return NotificationResponseDto.fromEntity(notification); + + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } +} From 6365d7763599f59bc7d5890f7f22aeaff8b8da74 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 11:06:37 +0900 Subject: [PATCH 71/78] =?UTF-8?q?chore:=20=ED=97=88=EC=9A=A9=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/lionsforest/global/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java index 547291a..f6e4fff 100644 --- a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -44,7 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwtTo // API 엔드포인트별 접근 권한 설정 .authorizeHttpRequests(auth -> auth // 인증 없이 무조건 통과(permitAll)하는 경로 - .requestMatchers("/auth/**","/swagger-ui/**", "/v3/api-docs/**" + .requestMatchers("/auth/**","/swagger-ui/**", "/v3/api-docs/**", "/api/health" ).permitAll() //api 및 다른 경로: 인증 되면 통과 .requestMatchers("/api/**").authenticated() From c9536e4566dc2fb07d3fc1e1ae687010bdbc4120 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 18 Nov 2025 15:07:40 +0900 Subject: [PATCH 72/78] =?UTF-8?q?fix:=20targetId=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/comment/service/CommentService.java | 8 ++++---- .../lionsforest/domain/review/service/ReviewService.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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 index aeea8d0..938b34c 100644 --- a/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/lionsforest/domain/comment/service/CommentService.java @@ -73,8 +73,8 @@ public CommentResponseDto createComment(Long groupId, CommentRequestDto dto, Lon .user(part.getUser()) .content(content) .photo(photoPath) - .targetId(comment.getCommentId()) - .targetType(TargetType.COMMENT) + .targetId(groupId) + .targetType(TargetType.GROUP) .build(); notificationRepository.save(notification); } @@ -148,8 +148,8 @@ public String toggleLike(Long commentId, Long userId){ .user(author) .content(content) .photo(photoPath) - .targetId(commentId) - .targetType(TargetType.COMMENT) + .targetId(group.getId()) + .targetType(TargetType.GROUP) .build(); notificationRepository.save(notification); } 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 index b349420..65ebdea 100644 --- a/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/lionsforest/domain/review/service/ReviewService.java @@ -107,8 +107,8 @@ public ReviewResponseDto createReview(Long groupId, .user(part.getUser()) .content(content) .photo(photoPath) - .targetId(review.getId()) - .targetType(TargetType.REVIEW) + .targetId(groupId) + .targetType(TargetType.GROUP) .build(); notificationRepository.save(notification); } From 6f7bce83c7db26da85f6c501c839d3f1509ef238 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 18 Nov 2025 15:53:57 +0900 Subject: [PATCH 73/78] =?UTF-8?q?fix:=20targetType=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notification/service/NotificationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 3e5edf9..7431292 100644 --- a/src/main/java/com/example/lionsforest/domain/notification/service/NotificationService.java +++ b/src/main/java/com/example/lionsforest/domain/notification/service/NotificationService.java @@ -34,7 +34,7 @@ public NotificationResponseDto createRadarLikeNotification(Long senderId, Long r .photo(receiver.getProfile_photo()) .isRead(false) .targetId(receiverId) - .targetType(TargetType.GROUP) + .targetType(TargetType.RADAR) .build(); notificationRepository.save(notification); From 714c1d13ceafcd6226c9b434bf83a4c1718a5e10 Mon Sep 17 00:00:00 2001 From: limdaecheol Date: Tue, 18 Nov 2025 16:48:30 +0900 Subject: [PATCH 74/78] trigger deploy From 091de1de9153045a3656e684c3fa7d43a879b8dd Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 17:44:28 +0900 Subject: [PATCH 75/78] =?UTF-8?q?feat:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/lionsforest/domain/user/User.java | 1 + .../domain/user/service/UserService.java | 16 +++++++++++----- .../lionsforest/global/exception/ErrorCode.java | 9 ++++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/example/lionsforest/domain/user/User.java b/src/main/java/com/example/lionsforest/domain/user/User.java index 44a1b98..6f815c7 100644 --- a/src/main/java/com/example/lionsforest/domain/user/User.java +++ b/src/main/java/com/example/lionsforest/domain/user/User.java @@ -32,6 +32,7 @@ public class User extends BaseTimeEntity { @Column(nullable = false, length = 63) private String email; + @Column(length = 10) private String nickname; private String bio; 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 index 7b5eea2..37783dd 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/UserService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/UserService.java @@ -48,11 +48,17 @@ public UserInfoResponseDTO getUserInfo(Long userId) { public UserInfoResponseDTO updateUserInfo(Long userId, UserUpdateRequestDTO request) { User user = findUserById(userId); - // 닉네임 중복 검사 (변경 시에만) - if (request.getNickname() != null && - !request.getNickname().equals(user.getNickname()) && - userRepository.existsByNickname(request.getNickname())) { - throw new BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS); + // 닉네임 검사 (변경 시에만) + 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); + } } // 닉네임, 한 줄 소개 업데이트 diff --git a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java index 826cad9..f6e73b9 100644 --- a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -49,14 +49,17 @@ public enum ErrorCode { // 참여하지 않은 모임 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", "후기 조회에 실패했습니다"); + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW_NOT_FOUND", "후기 조회에 실패했습니다"), + + // 닉네임 생성 실패 + NICKNAME_LENGTH_EXCEEDED(HttpStatus.BAD_REQUEST, "NICKNANE_LENGTH_EXCEEDED", "닉네임은 10글자를 초과할 수 없습니다."); private final HttpStatus status; From 0079dab23192edd47b39cc985902dd0b334c980c Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 18:15:50 +0900 Subject: [PATCH 76/78] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B3=BC=EC=A0=95=20=EC=97=90=EB=9F=AC=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/service/AuthService.java | 14 ++++++----- .../global/component/GoogleOAuthService.java | 11 ++++++--- .../global/component/GoogleTokenVerifier.java | 8 ++++--- .../component/MemberWhitelistValidator.java | 4 +++- .../global/config/SecurityConfig.java | 1 + .../global/exception/ErrorCode.java | 23 ++++++++++++++++++- 6 files changed, 47 insertions(+), 14 deletions(-) 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 index f70ac73..2338874 100644 --- a/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java +++ b/src/main/java/com/example/lionsforest/domain/user/service/AuthService.java @@ -10,6 +10,8 @@ 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; @@ -35,14 +37,14 @@ public class AuthService { private final NicknameService nicknameService; private final GoogleOAuthService googleOAuthService; - @Value("%{google.auth.client-id}") + /*@Value("${google.auth.client-id}") private String clientId; @Value("${google.auth.client-secret}") private String clientSecret; @Value("${google.auth.redirect-uri}") - private String redirectUri; + private String redirectUri;*/ //code를 받아 로그인 처리하는 메소드(메인) public LoginResponseDTO loginWithGoogleCode(LoginRequestDTO loginRequestDTO) { @@ -58,7 +60,7 @@ public LoginResponseDTO processUserLogin(UserInfoRequestDTO userInfo) { //동아리 부원인지 조회 if (!whitelistValidator.isMember(name, email)) { - throw new SecurityException("동아리 부원 명단에 존재하지 않거나 정보가 일치하지 않습니다."); + throw new BusinessException(ErrorCode.USER_NOT_IN_WHITELIST); } //첫 가입인지 확인한 후 로그인 시킴 @@ -72,10 +74,10 @@ public LoginResponseDTO processUserLogin(UserInfoRequestDTO userInfo) { user.setNickname(userNickname); userRepository.save(user); isNewUser = true; - System.out.println("새 유저 생성!"); + log.info("새 유저 생성!"); } else { user = optionalUser.get(); - System.out.println("이미 존재하는 유저"); + log.info("이미 존재하는 유저"); } // Firebase 커스텀 토큰 생성 @@ -86,7 +88,7 @@ public LoginResponseDTO processUserLogin(UserInfoRequestDTO userInfo) { firebaseToken = FirebaseAuth.getInstance().createCustomToken(uid); }catch(FirebaseAuthException e){ log.error("Firebase 커스텀 토큰 생성 실패(User ID: {}): {}", user.getId(), e.getMessage()); - throw new RuntimeException("Firebase 토큰 생성 중 오류가 발생했습니다.", e); + throw new BusinessException(ErrorCode.FIREBASE_TOKEN_CREATION_FAILED); } // JWT 토큰 생성 (반환 타입이 TokenResponse DTO임) diff --git a/src/main/java/com/example/lionsforest/global/component/GoogleOAuthService.java b/src/main/java/com/example/lionsforest/global/component/GoogleOAuthService.java index 73a6e1b..ed71389 100644 --- a/src/main/java/com/example/lionsforest/global/component/GoogleOAuthService.java +++ b/src/main/java/com/example/lionsforest/global/component/GoogleOAuthService.java @@ -2,6 +2,8 @@ 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; @@ -53,7 +55,7 @@ public UserInfoRequestDTO getUserInfo(LoginRequestDTO request) { //토큰 유효성 검증 if(!idToken.verifyAudience(Collections.singletonList(clientId))){ - throw new SecurityException("Google ID Token의 Client ID가 유효하지 않습니다."); + throw new BusinessException(ErrorCode.INVALID_GOOGLE_ID_TOKEN); } GoogleIdToken.Payload payload = idToken.getPayload(); @@ -63,7 +65,7 @@ public UserInfoRequestDTO getUserInfo(LoginRequestDTO request) { String profilePhoto = (String) payload.get("picture"); if(email == null || name == null){ - throw new SecurityException("Google 토큰에서 이메일 또는 이름 정보를 가져올 수 없습니다."); + throw new BusinessException(ErrorCode.GOOGLE_USER_INFO_NOT_FOUND); } return UserInfoRequestDTO.builder() @@ -73,7 +75,10 @@ public UserInfoRequestDTO getUserInfo(LoginRequestDTO request) { .build(); } catch(IOException e){ log.error("Google 인증 코드 교환 또는 토큰 파싱 실패: {}", e.getMessage(), e); - throw new RuntimeException("Google 인증 중 오류가 발생했습니다.", e); + throw new BusinessException(ErrorCode.GOOGLE_SERVER_ERROR); + } catch(Exception e){ // 그 외 오류 + log.error("Google 로그인 중 알 수 없는 오류: {}", e.getMessage(), e); + throw new BusinessException(ErrorCode.GOOGLE_LOGIN_FAILED); } } diff --git a/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java b/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java index 39e3444..bfd3a0f 100644 --- a/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java +++ b/src/main/java/com/example/lionsforest/global/component/GoogleTokenVerifier.java @@ -1,6 +1,8 @@ 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; @@ -29,12 +31,12 @@ public GoogleTokenVerifier(@Value("${google.auth.client-id}") String clientId) { public UserInfoRequestDTO verify(String idToken) { try { if (clientId == null || clientId.isBlank() || clientId.contains("YOUR_GOOGLE_CLIENT_ID")) { - throw new IllegalArgumentException("Google Client ID가 application.yml에 설정되지 않았습니다."); + throw new BusinessException(ErrorCode.GOOGLE_CLIENT_ID_CONFIG_ERROR); } GoogleIdToken googleIdToken = verifier.verify(idToken); if (googleIdToken == null) { - throw new SecurityException("유효하지 않은 구글 ID 토큰입니다."); + throw new BusinessException(ErrorCode.INVALID_GOOGLE_ID_TOKEN); } GoogleIdToken.Payload payload = googleIdToken.getPayload(); @@ -43,7 +45,7 @@ public UserInfoRequestDTO verify(String idToken) { String profile_photo = (String) payload.get("picture"); if (email == null || name == null) { - throw new SecurityException("구글 토큰에서 이메일 또는 이름 정보를 가져올 수 없습니다."); + throw new BusinessException(ErrorCode.GOOGLE_USER_INFO_NOT_FOUND); } return UserInfoRequestDTO.builder() diff --git a/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java b/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java index 9847711..7614ec0 100644 --- a/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java +++ b/src/main/java/com/example/lionsforest/global/component/MemberWhitelistValidator.java @@ -1,5 +1,6 @@ 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; @@ -41,7 +42,8 @@ public void loadWhitelist() { log.info("Whitelist contents: {}", whitelist); }catch(IOException e){ - log.error("Failed to load whitelist", e); + log.error("Failed to load whitelist: {}", ErrorCode.WHITELIST_LOAD_FAILED); + throw new RuntimeException("Application startup failed due to Whitelist file loading error.", e); } } diff --git a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java index f6e4fff..1a4905c 100644 --- a/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java +++ b/src/main/java/com/example/lionsforest/global/config/SecurityConfig.java @@ -73,6 +73,7 @@ public CorsConfigurationSource corsConfigurationSource() { "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" diff --git a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java index f6e73b9..e1c3d98 100644 --- a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -59,7 +59,28 @@ public enum ErrorCode { REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW_NOT_FOUND", "후기 조회에 실패했습니다"), // 닉네임 생성 실패 - NICKNAME_LENGTH_EXCEEDED(HttpStatus.BAD_REQUEST, "NICKNANE_LENGTH_EXCEEDED", "닉네임은 10글자를 초과할 수 없습니다."); + 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; From ae2028f367a03d671a31eeef5107b4f2470719da Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 18:37:55 +0900 Subject: [PATCH 77/78] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A4=20=EC=8B=9C=EA=B0=81=20=EA=B2=80=EC=A6=9D=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lionsforest/domain/group/service/GroupService.java | 5 +++++ .../com/example/lionsforest/global/exception/ErrorCode.java | 3 +++ 2 files changed, 8 insertions(+) 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 index cda9ec4..387643d 100644 --- a/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java +++ b/src/main/java/com/example/lionsforest/domain/group/service/GroupService.java @@ -49,6 +49,11 @@ 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); diff --git a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java index e1c3d98..172a180 100644 --- a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -37,6 +37,9 @@ public enum ErrorCode { // 모임 취소 시점 제한 GROUP_CANCEL_TIME_EXCEEDED(HttpStatus.BAD_REQUEST, "GROUP_CANCEL_TIME_EXCEEDED", "모임 시작 이후에는 취소할 수 없습니다."), + // 모임 생성 시점 제한 + GROUP_CREATION_TIME_EXCEEDED(HttpStatus.BAD_REQUEST, "GROUP_CANCEL_TIME_EXCEEDED", "모임 개설은 현재 시각 이후로만 가능합니다."), + // 모임 중복 신청 제한 PARTICIPATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "PARTICIPATION_ALREADY_EXISTS", "이미 참여 중인 모임입니다."), From 97803772b86eceba0ba3f1ae3c8e36458fd3c063 Mon Sep 17 00:00:00 2001 From: chaeyeonlee898 Date: Tue, 18 Nov 2025 20:51:56 +0900 Subject: [PATCH 78/78] =?UTF-8?q?fix:=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/lionsforest/global/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java index 172a180..d0d00eb 100644 --- a/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java +++ b/src/main/java/com/example/lionsforest/global/exception/ErrorCode.java @@ -38,7 +38,7 @@ public enum ErrorCode { GROUP_CANCEL_TIME_EXCEEDED(HttpStatus.BAD_REQUEST, "GROUP_CANCEL_TIME_EXCEEDED", "모임 시작 이후에는 취소할 수 없습니다."), // 모임 생성 시점 제한 - GROUP_CREATION_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", "이미 참여 중인 모임입니다."),