diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..72eafb7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +/gradlew text eol=lf + +*.bat text eol=crlf +*.jar binary diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 0000000..e1ccee2 --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,54 @@ +name: BookPick BE dev CI/CD (v 1.3) + +on: + push: + branches: + - develop + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and Push to ECR + run: | + docker build --platform linux/amd64 -t 219268921033.dkr.ecr.ap-northeast-2.amazonaws.com/bookpick:1.0.0 . + docker push 219268921033.dkr.ecr.ap-northeast-2.amazonaws.com/bookpick:1.0.0 + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: Set up SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ secrets.DEV_SERVER_HOST }} >> ~/.ssh/known_hosts + + - name: Deploy to EC2 + run: | + ssh -i ~/.ssh/id_rsa ${{ secrets.SERVER_USER }}@${{ secrets.DEV_SERVER_HOST }} 'bash -s' < ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ secrets.PROD_SERVER_HOST }} >> ~/.ssh/known_hosts + + - name: Deploy to EC2 + run: | + ssh -i ~/.ssh/id_rsa ${{ secrets.SERVER_USER }}@${{ secrets.PROD_SERVER_HOST }} 'bash -l -s' < '} + 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..013308e --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'mvp' diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..9f7b6b2 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 0000000..9a0d26a Binary files /dev/null and b/src/main/.DS_Store differ diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store new file mode 100644 index 0000000..8884665 Binary files /dev/null and b/src/main/java/.DS_Store differ diff --git a/src/main/java/BookPick/.DS_Store b/src/main/java/BookPick/.DS_Store new file mode 100644 index 0000000..668a7c8 Binary files /dev/null and b/src/main/java/BookPick/.DS_Store differ diff --git a/src/main/java/BookPick/mvp/.DS_Store b/src/main/java/BookPick/mvp/.DS_Store new file mode 100644 index 0000000..9ebae6b Binary files /dev/null and b/src/main/java/BookPick/mvp/.DS_Store differ diff --git a/src/main/java/BookPick/mvp/BookPickApplication.java b/src/main/java/BookPick/mvp/BookPickApplication.java new file mode 100644 index 0000000..fd8c264 --- /dev/null +++ b/src/main/java/BookPick/mvp/BookPickApplication.java @@ -0,0 +1,16 @@ +package BookPick.mvp; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing // ← 이거 있어? + +public class BookPickApplication { + + public static void main(String[] args) { + SpringApplication.run(BookPickApplication.class, args); + } + +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/Exception/fail/AlreadyRegisteredReadingPreferenceException.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/Exception/fail/AlreadyRegisteredReadingPreferenceException.java new file mode 100644 index 0000000..86b3abd --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/Exception/fail/AlreadyRegisteredReadingPreferenceException.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.ReadingPreference.Exception.fail; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class AlreadyRegisteredReadingPreferenceException extends BusinessException { + public AlreadyRegisteredReadingPreferenceException(){ + super(ErrorCode.READING_PREFERENCE_ALREADY_RESiGSTER); + } +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/Exception/fail/UserReadingPreferenceNotExisted.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/Exception/fail/UserReadingPreferenceNotExisted.java new file mode 100644 index 0000000..3077ccc --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/Exception/fail/UserReadingPreferenceNotExisted.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.ReadingPreference.Exception.fail; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class UserReadingPreferenceNotExisted extends BusinessException { + public UserReadingPreferenceNotExisted(){ + super(ErrorCode.READING_PREFERENCE_NOT_EXISTED); + } +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/Exception/fail/WrongReadingPreferenceRequestException.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/Exception/fail/WrongReadingPreferenceRequestException.java new file mode 100644 index 0000000..c57acb5 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/Exception/fail/WrongReadingPreferenceRequestException.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.ReadingPreference.Exception.fail; + +import BookPick.mvp.domain.ReadingPreference.enums.resCode.PreferenceErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class WrongReadingPreferenceRequestException extends BusinessException { + public WrongReadingPreferenceRequestException(){ + super(PreferenceErrorCode.WRONG_READING_PREFERENCE_REQUEST); + } +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/controller/ReadingPreferenceController.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/controller/ReadingPreferenceController.java new file mode 100644 index 0000000..7ef307a --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/controller/ReadingPreferenceController.java @@ -0,0 +1,74 @@ +package BookPick.mvp.domain.ReadingPreference.controller; + +import BookPick.mvp.domain.ReadingPreference.dto.ETC.Delete.ReadingPreferenceDeleteRes; +import BookPick.mvp.domain.ReadingPreference.dto.ReadingPreferenceReq; +import BookPick.mvp.domain.ReadingPreference.dto.ReadingPreferenceRes; +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.ReadingPreference.service.ReadingPreferenceService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.Operation; + +@RestController +@RequestMapping("/api/v1/reading-preference") +@RequiredArgsConstructor +public class ReadingPreferenceController { + + private final ReadingPreferenceService readingPreferenceService; + private final CurrentUserCheck currentUserCheck; + + @Operation(summary = "독서 취향 생성", description = "사용자의 독서 취향을 등록합니다", tags = {"Reading Preference"}) + @PostMapping + public ResponseEntity> create( + @Valid @RequestBody ReadingPreferenceReq req, + @AuthenticationPrincipal CustomUserDetails currentUser) { + ReadingPreferenceRes res = readingPreferenceService.addReadingPreference(currentUser.getId(), req); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(SuccessCode.READING_PREFERENCE_REGISTER_SUCCESS, res)); + } + + @Operation(summary = "독서 취향 조회", description = "사용자의 독서 취향 상세 조회", tags = {"Reading Preference"}) + @GetMapping + public ResponseEntity> getDetails( + @AuthenticationPrincipal CustomUserDetails currentUser) { + + currentUserCheck.validateLoginUser(currentUser); + + ReadingPreferenceRes res = readingPreferenceService.findReadingPreference(currentUser.getId()); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.READING_PREFERENCE_READ_SUCCESS, res)); + } + + @Operation(summary = "독서 취향 수정", description = "사용자의 독서 취향을 수정합니다", tags = {"Reading Preference"}) + @PatchMapping + public ResponseEntity> update( + @Valid @RequestBody ReadingPreferenceReq req, + @AuthenticationPrincipal CustomUserDetails currentUser) { + ReadingPreferenceRes res = readingPreferenceService.modifyReadingPreference(currentUser.getId(), req); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.READING_PREFERENCE_UPDATE_SUCCESS, res)); + } + + @Operation(summary = "독서 취향 삭제", description = "사용자의 독서 취향을 삭제합니다", tags = {"Reading Preference"}) + @DeleteMapping + public ResponseEntity> delete( + @AuthenticationPrincipal CustomUserDetails currentUser) { + ReadingPreferenceDeleteRes res = readingPreferenceService.removeReadingPreference(currentUser.getId()); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.READING_PREFERENCE_DELETE_SUCCESS, res)); + } +} + + diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/dto/ETC/Delete/ReadingPreferenceDeleteRes.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/dto/ETC/Delete/ReadingPreferenceDeleteRes.java new file mode 100644 index 0000000..e8ead7e --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/dto/ETC/Delete/ReadingPreferenceDeleteRes.java @@ -0,0 +1,13 @@ +package BookPick.mvp.domain.ReadingPreference.dto.ETC.Delete; + +import java.time.LocalDateTime; + +public record ReadingPreferenceDeleteRes( + Long preferenceId, +// boolean deleted, // 추후 soft 삭제 시 사용 + LocalDateTime deletedAt +) { + static public ReadingPreferenceDeleteRes from(Long preferenceId, LocalDateTime deletedAt){ + return new ReadingPreferenceDeleteRes(preferenceId, deletedAt); + } +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/dto/ReadingPreferenceReq.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/dto/ReadingPreferenceReq.java new file mode 100644 index 0000000..bd88b02 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/dto/ReadingPreferenceReq.java @@ -0,0 +1,21 @@ +package BookPick.mvp.domain.ReadingPreference.dto; + +import BookPick.mvp.domain.author.dto.preference.AuthorDto; +import BookPick.mvp.domain.author.entity.Author; +import BookPick.mvp.domain.book.dto.preference.BookDto; +import BookPick.mvp.domain.book.entity.Book; + +import java.util.List; +import java.util.Set; + +public record ReadingPreferenceReq( + String mbti, + Set favoriteBooks, // 좋아하는 책 + Set favoriteAuthors, + List moods, // 독서 선호 분위기 + List readingHabits, // 독서 습관 + List genres, // 선호 장르 + List keywords, // 키워드 + List readingStyles // +) { + } diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/dto/ReadingPreferenceRes.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/dto/ReadingPreferenceRes.java new file mode 100644 index 0000000..5be65e2 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/dto/ReadingPreferenceRes.java @@ -0,0 +1,59 @@ +package BookPick.mvp.domain.ReadingPreference.dto; + + +import BookPick.mvp.domain.ReadingPreference.entity.ReadingPreference; +import BookPick.mvp.domain.author.dto.preference.AuthorDto; +import BookPick.mvp.domain.book.dto.preference.BookRes; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public record ReadingPreferenceRes( + Long preferenceId, + String mbti, + Set favoriteBooks, // 좋아하는 책 + Set favoriteAuthors, + List moods, // 독서 선호 분위기 + List readingHabits, // 독서 습관 + List genres, // 선호 장르 + List keywords, // 키워드 + List readingStyles // +) { + + static public ReadingPreferenceRes from(ReadingPreference rp) { + + Set favoriteBooks = rp.getFavoriteBooks().stream() + .map(book -> new BookRes( + book.getTitle(), + book.getAuthor().getName(), // author 이름만 + book.getImage(), + book.getIsbn() + )) + .collect(Collectors.toSet()); + + Set favoriteAuthors = rp.getFavoriteAuthors().stream() + .map(author -> new AuthorDto(author.getName())) + .collect(Collectors.toSet()); + + return new ReadingPreferenceRes( + rp.getId(), + rp.getMbti(), + favoriteBooks, + favoriteAuthors, + rp.getMoods(), + rp.getReadingHabits(), + rp.getGenres(), + rp.getKeywords(), + rp.getReadingStyles() + ); + + } + + +} + + + + + diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/entity/ReadingPreference.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/entity/ReadingPreference.java new file mode 100644 index 0000000..a2e6ab9 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/entity/ReadingPreference.java @@ -0,0 +1,169 @@ +package BookPick.mvp.domain.ReadingPreference.entity; + +import BookPick.mvp.domain.ReadingPreference.dto.ReadingPreferenceReq; +import BookPick.mvp.domain.author.entity.Author; +import BookPick.mvp.domain.author.service.AuthorSaveService; +import BookPick.mvp.domain.book.dto.preference.BookDto; +import BookPick.mvp.domain.book.entity.Book; +import BookPick.mvp.domain.book.service.BookSaveService; +import BookPick.mvp.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.*; + +@Entity +@Table(name = "reading_preference") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReadingPreference { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long Id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", unique = true, nullable = false) + private User user; + + private String mbti; + + @ManyToMany + @JoinTable( + name = "preference_books", + joinColumns = @JoinColumn(name = "preference_id"), + inverseJoinColumns = @JoinColumn(name = "book_id") + ) + private Set favoriteBooks; + + @ManyToMany + @JoinTable( + name = "preference_authors", + joinColumns = @JoinColumn(name = "preference_id"), + inverseJoinColumns = @JoinColumn(name = "author_id") + ) + private Set favoriteAuthors; + + + @ElementCollection + @CollectionTable(name = "preference_moods", joinColumns = @JoinColumn(name = "preference_id")) + @Column(name = "mood") + private List moods; + + @ElementCollection + @CollectionTable(name = "preference_readinghabits", joinColumns = @JoinColumn(name = "preference_id")) + @Column(name = "reading_habits") + private List readingHabits; + + @ElementCollection + @CollectionTable(name = "preference_genres", joinColumns = @JoinColumn(name = "preference_id")) + @Column(name = "genre") + private List genres; + + @ElementCollection + @CollectionTable(name = "preference_keywords", joinColumns = @JoinColumn(name = "preference_id")) + @Column(name = "keyword") + private List keywords; + + @ElementCollection + @CollectionTable(name = "preference_readingstyles", joinColumns = @JoinColumn(name = "preference_id")) + @Column(name = "reading_style") + private List readingStyles; + + private boolean isCompleted = false; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + private LocalDateTime deletedAt; + + /* public void update(ReadingPreferenceReq req) { +// if (req.mbti() != null) this.mbti = req.mbti(); // mbti null값 허용 + + + + if (req.moods() != null) { + this.moods.clear(); + this.moods.addAll(req.moods()); + } + else{ + this.moods.clear(); + } + + if (req.readingHabits() != null) { + this.readingHabits.clear(); + this.readingHabits.addAll(req.readingHabits()); + } + + if (req.genres() != null) { + this.genres.clear(); + this.genres.addAll(req.genres()); + } + + if (req.keywords() != null) { + this.keywords.clear(); + this.keywords.addAll(req.keywords()); + } + + if (req.readingStyles() != null) { + this.readingStyles.clear(); + this.readingStyles.addAll(req.readingStyles()); + } + }*/ + + public void update(ReadingPreferenceReq req) { + + this.mbti = req.mbti(); + + this.moods.clear(); + if (req.moods() != null) { + this.moods.addAll(req.moods()); + } + + this.readingHabits.clear(); + if (req.readingHabits() != null) { + this.readingHabits.addAll(req.readingHabits()); + } + + this.genres.clear(); + if (req.genres() != null) { + this.genres.addAll(req.genres()); + } + + this.keywords.clear(); + if (req.keywords() != null) { + this.keywords.addAll(req.keywords()); + } + + this.readingStyles.clear(); + if (req.readingStyles() != null) { + this.readingStyles.addAll(req.readingStyles()); + } + } + + + public static ReadingPreference clearPreferences(User user) { + + return ReadingPreference.builder() + .user(user) + .mbti(null) + .favoriteBooks(new HashSet<>()) + .favoriteAuthors(new HashSet<>()) + .moods(new ArrayList<>()) + .readingHabits(new ArrayList<>()) + .genres(new ArrayList<>()) + .keywords(new ArrayList<>()) + .readingStyles(new ArrayList<>()) + .isCompleted(false) + .build(); + } + + +} + + diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/Genre.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/Genre.java new file mode 100644 index 0000000..a491655 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/Genre.java @@ -0,0 +1,34 @@ +package BookPick.mvp.domain.ReadingPreference.enums.filed; + +public enum Genre implements PreferenceField { + + NOVEL("소설"), + ESSAY("에세이"), + HISTORY("역사"), + ART("예술"), + SELF_DEVELOPMENT("자기개발"), + ECONOMY("경제"), + PSYCHOLOGY("심리학"), + SOCIETY("사회"), + EDUCATION("교육"), + SCIENCE("과학"), + PHILOSOPHY("철학"), + RELIGION("종교"); + + private final String description; + + Genre(String description) { + this.description = description; + } + + @Override + public String getDescription() { + return description; + } + + // ✔ description 기반 유효성 검증 (한 줄 래핑) + public static boolean isValid(String description) { + return PreferenceField.isValidDescription(Genre.class, description); + } + +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/Keyword.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/Keyword.java new file mode 100644 index 0000000..138a5db --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/Keyword.java @@ -0,0 +1,34 @@ +package BookPick.mvp.domain.ReadingPreference.enums.filed; + +public enum Keyword implements PreferenceField { + + COMFORT("위로"), + GROWTH("성장"), + LOVE("사랑"), + EMPATHY("공감"), + KNOWLEDGE("지식"), + HUMOR("유머"), + MYSTERY("추리"), + ADVENTURE("모험"), + FANTASY("판타지"), + REALITY("현실"), + FUTURE("미래"), + PAST("과거"), + HORROR("공포"); + + private final String description; + + Keyword(String description) { + this.description = description; + } + + @Override + public String getDescription() { + return description; + } + + public static boolean isValid(String description) { + return PreferenceField.isValidDescription(Keyword.class, description); + } +} + diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/MBTI.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/MBTI.java new file mode 100644 index 0000000..f450885 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/MBTI.java @@ -0,0 +1,31 @@ +package BookPick.mvp.domain.ReadingPreference.enums.filed; + +public enum MBTI { + + + ISTJ, + ISFJ, + INFJ, + INTJ, + ISTP, + ISFP, + INFP, + INTP, + ESTP, + ESFP, + ENFP, + ENTP, + ESTJ, + ESFJ, + ENFJ, + ENTJ; + + public static boolean isValid(String value){ + try { + MBTI.valueOf(value); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/Mood.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/Mood.java new file mode 100644 index 0000000..b96b4cc --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/Mood.java @@ -0,0 +1,40 @@ +package BookPick.mvp.domain.ReadingPreference.enums.filed; + +public enum Mood implements PreferenceField { + + AFTER_WORK("퇴근 후"), + HOT_TEA("따뜻한 차 한잔"), + RAINY_DAY("비 오는 날"), + SNOWY_DAY("눈 오는 날"), + SUBWAY_BUS("지하철·버스"), + CAFE("카페"), + BED("침대에서"), + PARK("공원"), + LIBRARY("도서관"), + BOOKSTORE("서점에서"), + DAWN("새벽 시간"), + WEEKEND_AFTERNOON("주말 오후"), + LUNCH_TIME("점심시간"), + LATE_NIGHT("늦은 밤"), + BEFORE_SLEEP("잠들기 전"), + ALONE_TIME("혼자만의 시간"), + WINDOW_SIDE("창가에서"), + WITH_MUSIC("음악과 함께"), + TRAVELING("여행 중"), + VACATION("휴가 중"); + + private final String description; + + Mood(String description) { + this.description = description; + } + + @Override + public String getDescription() { + return description; + } + + public static boolean isValid(String description) { + return PreferenceField.isValidDescription(Mood.class, description); + } +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/PreferenceField.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/PreferenceField.java new file mode 100644 index 0000000..c68ffea --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/PreferenceField.java @@ -0,0 +1,27 @@ +package BookPick.mvp.domain.ReadingPreference.enums.filed; + +import java.util.function.Function; + + +import java.util.function.Function; + +public interface PreferenceField { + + String getDescription(); + + static & PreferenceField> boolean isValidDescription( + Class enumClass, + String description + ) { + if (description == null || description.isEmpty()) { + return true; + } + for (E constant : enumClass.getEnumConstants()) { + if (constant.getDescription().equals(description)) { + return true; + } + } + return false; + } +} + diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/ReadingHabit.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/ReadingHabit.java new file mode 100644 index 0000000..8de45f3 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/ReadingHabit.java @@ -0,0 +1,30 @@ +package BookPick.mvp.domain.ReadingPreference.enums.filed; + +public enum ReadingHabit implements PreferenceField{ + + READ_AT_ONCE("한 번에 완독하는 편"), + HIGHLIGHTING("밑줄 긋거나 형광펜으로 표시하는 편"), + MULTIPLE_BOOKS("여러 권을 동시에 읽는 편"), + MANY_BOOKMARKS("책갈피를 많이 사용하는 편"), + NOTE_TAKING("읽은 내용을 메모하는 편"), + READ_OUT_LOUD("소리 내어 읽는 편"), + QUIET_PLACE("조용한 곳에서만 읽는 편"), + WITH_MUSIC("음악을 들으며 읽는 편"), + REREADING("읽은 책을 다시 읽는 편"), + READING_CLUB("독서 모임에 참여하는 편"); + + private final String description; + + ReadingHabit(String description) { + this.description = description; + } + + @Override + public String getDescription() { + return description; + } + + public static boolean isValid(String description) { + return PreferenceField.isValidDescription(ReadingHabit.class, description); + } +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/ReadingStyle.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/ReadingStyle.java new file mode 100644 index 0000000..5706a90 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/filed/ReadingStyle.java @@ -0,0 +1,35 @@ +package BookPick.mvp.domain.ReadingPreference.enums.filed; + +public enum ReadingStyle implements PreferenceField { + + SPEED_READING("속독형"), + IMMERSIVE("몰입형"), + CAREFUL_READING("정독형"), + EXPLORATORY("취향 탐색형"), + STORY_FOCUSED("스토리 중심"), + KNOWLEDGE_BASED("지식 위주"), + EMOTIONAL("감성적"), + LOGICAL("논리적"), + CREATIVE("창의적"), + PRACTICAL("실용적"), + CRITICAL("비평적"), + IMAGINATIVE("상상력 중시"), + RELAXED("느긋한 독서"), + DEEP_THINKING("깊이 있는 사색"), + LIGHT_READING("가볍게 즐기기"); + + private final String description; + + ReadingStyle(String description) { + this.description = description; + } + + @Override + public String getDescription() { + return description; + } + + public static boolean isValid(String description) { + return PreferenceField.isValidDescription(ReadingStyle.class, description); + } +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/resCode/PreferenceErrorCode.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/resCode/PreferenceErrorCode.java new file mode 100644 index 0000000..05ea34e --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/resCode/PreferenceErrorCode.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.ReadingPreference.enums.resCode; + +import BookPick.mvp.global.enums.ErrorCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum PreferenceErrorCode implements ErrorCodeInterface { + + WRONG_READING_PREFERENCE_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 독서취향 요청값입니다."); + + + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/resCode/PreferenceSuccessCode.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/resCode/PreferenceSuccessCode.java new file mode 100644 index 0000000..05a0b94 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/enums/resCode/PreferenceSuccessCode.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.ReadingPreference.enums.resCode; + +import BookPick.mvp.global.enums.ErrorCodeInterface; +import BookPick.mvp.global.enums.SuccessCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum PreferenceSuccessCode implements SuccessCodeInterface { + + + PREFERENCE_NOT_FOUND(HttpStatus.OK, "사용자의 독서취향이 설정되지 않았습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/repository/ReadingPreferenceRepository.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/repository/ReadingPreferenceRepository.java new file mode 100644 index 0000000..92d9029 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/repository/ReadingPreferenceRepository.java @@ -0,0 +1,11 @@ +package BookPick.mvp.domain.ReadingPreference.repository; + +import BookPick.mvp.domain.ReadingPreference.entity.ReadingPreference; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ReadingPreferenceRepository extends JpaRepository { + boolean existsByUserId(Long userId); + Optional findByUserId(Long userId); +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceService.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceService.java new file mode 100644 index 0000000..57181cb --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceService.java @@ -0,0 +1,156 @@ +package BookPick.mvp.domain.ReadingPreference.service; + +import BookPick.mvp.domain.ReadingPreference.Exception.fail.AlreadyRegisteredReadingPreferenceException; +import BookPick.mvp.domain.ReadingPreference.Exception.fail.UserReadingPreferenceNotExisted; +import BookPick.mvp.domain.ReadingPreference.dto.ETC.Delete.ReadingPreferenceDeleteRes; +import BookPick.mvp.domain.ReadingPreference.dto.ReadingPreferenceReq; +import BookPick.mvp.domain.ReadingPreference.dto.ReadingPreferenceRes; +import BookPick.mvp.domain.ReadingPreference.entity.ReadingPreference; +import BookPick.mvp.domain.ReadingPreference.repository.ReadingPreferenceRepository; +import BookPick.mvp.domain.author.entity.Author; +import BookPick.mvp.domain.author.service.AuthorSaveService; +import BookPick.mvp.domain.book.entity.Book; +import BookPick.mvp.domain.book.repository.BookRepository; +import BookPick.mvp.domain.book.service.BookSaveService; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class ReadingPreferenceService { + private final ReadingPreferenceRepository readingPreferenceRepository; + private final UserRepository userRepository; + private final BookSaveService bookSaveService; + private final AuthorSaveService authorSaveService; + private final BookRepository bookRepository; + private final ReadingPreferenceValidCheckService readingPreferenceValidCheckService; + + + + // -- 유저 독서 취향 등록 -- + @Transactional + public ReadingPreferenceRes addReadingPreference(Long userId, ReadingPreferenceReq req) { + + + // 1. 유저 검색 + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + // 2. 독서취향이 이미 존재하면 이미 존재하는 독서취향입니다. + if (readingPreferenceRepository.existsByUserId(userId)) { + throw new AlreadyRegisteredReadingPreferenceException(); + } + + // 3. 독서취향 기반 책 저장 (중복 체크) + Set savedBooks = bookSaveService.saveBookIfNotExistsDto(req.favoriteBooks()); + + + // 4. 독서취향 기반 작가 저장 (중복 체크) + Set savedAuthors = authorSaveService.saveAuthorIfNotExistsDto(req.favoriteAuthors()); + + // 5. 책 찾고 + ReadingPreference readingPreference = ReadingPreference.builder() + .user(user) + .mbti(req.mbti()) + .favoriteBooks(savedBooks) + .favoriteAuthors(savedAuthors) + .moods(req.moods()) + .readingHabits(req.readingHabits()) + .genres(req.genres()) + .readingStyles(req.readingStyles()) + .keywords(req.keywords()) + .build(); + + ReadingPreference saved = readingPreferenceRepository.save(readingPreference); + + + return ReadingPreferenceRes.from(saved); + } + + // -- 빈 독서취향 등록 -- + @Transactional + public ReadingPreferenceRes addClearReadingPreference(Long userId) { + + + // 1. 유저 검색 + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + // 2. 독서취향이 이미 존재하면 이미 존재하는 독서취향입니다. + if (readingPreferenceRepository.existsByUserId(userId)) { + throw new AlreadyRegisteredReadingPreferenceException(); + } + + // 3. 처음 가입한 유저도 독서취향 설정할 수 있게, 회원가입시 빈 독서취향 등록하는 로직 추가 + ReadingPreference saved = readingPreferenceRepository.save(ReadingPreference.clearPreferences(user)); + + return ReadingPreferenceRes.from(saved); + } + + // -- 유저 독서 취향 단건 조회 -- + @Transactional + public ReadingPreferenceRes findReadingPreference(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + ReadingPreference result = readingPreferenceRepository.findByUserId(userId) + .orElseThrow(UserReadingPreferenceNotExisted::new); + + return ReadingPreferenceRes.from(result); + } + + // -- 본인 유저 독서 취향 수정 -- + @Transactional + public ReadingPreferenceRes modifyReadingPreference(Long userId, ReadingPreferenceReq req) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + ReadingPreference preference = readingPreferenceRepository.findByUserId(userId) + .orElseThrow(UserReadingPreferenceNotExisted::new); + + + Set savedBooks = bookSaveService.saveBookIfNotExistsDto(req.favoriteBooks()); + + Set savedAuthors = authorSaveService.saveAuthorIfNotExistsDto(req.favoriteAuthors()); + + preference.setFavoriteBooks(savedBooks); + preference.setFavoriteAuthors(savedAuthors); + + if(!preference.isCompleted()) { + preference.setCompleted(true); + } + + + readingPreferenceValidCheckService.validateReadingPreferenceReq(req); // ReadingPreferenceReq 검증 + preference.update(req); + + + + return ReadingPreferenceRes.from(preference); + } + + // -- 본인 유저 독서 삭제 -- + @Transactional + public ReadingPreferenceDeleteRes removeReadingPreference(Long userId) { + + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + ReadingPreference preference = readingPreferenceRepository.findByUserId(userId) + .orElseThrow(UserReadingPreferenceNotExisted::new); + + readingPreferenceRepository.delete(preference); + + + return ReadingPreferenceDeleteRes.from(preference.getId(), LocalDateTime.now()); + + } + +} diff --git a/src/main/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceValidCheckService.java b/src/main/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceValidCheckService.java new file mode 100644 index 0000000..e4af3ba --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceValidCheckService.java @@ -0,0 +1,59 @@ +package BookPick.mvp.domain.ReadingPreference.service; + +import BookPick.mvp.domain.ReadingPreference.Exception.fail.WrongReadingPreferenceRequestException; +import BookPick.mvp.domain.ReadingPreference.dto.ReadingPreferenceReq; +import BookPick.mvp.domain.ReadingPreference.enums.filed.*; +import org.springframework.stereotype.Service; + +@Service +public class ReadingPreferenceValidCheckService { + + public void validateReadingPreferenceReq(ReadingPreferenceReq req) { + + if (req.mbti() != null && !req.mbti().isEmpty()) { + if (!MBTI.isValid(req.mbti())) { + throw new WrongReadingPreferenceRequestException(); + } + } + + if (req.moods() != null) { + for (String mood : req.moods()) { + if (!Mood.isValid(mood)) { + throw new WrongReadingPreferenceRequestException(); + } + } + } + + if (req.readingHabits() != null) { + for (String habit : req.readingHabits()) { + if (!ReadingHabit.isValid(habit)) { + throw new WrongReadingPreferenceRequestException(); + } + } + } + + if (req.genres() != null) { + for (String genre : req.genres()) { + if (!Genre.isValid(genre)) { + throw new WrongReadingPreferenceRequestException(); + } + } + } + + if (req.keywords() != null) { + for (String keyword : req.keywords()) { + if (!Keyword.isValid(keyword)) { + throw new WrongReadingPreferenceRequestException(); + } + } + } + + if (req.readingStyles() != null) { + for (String style : req.readingStyles()) { + if (!ReadingStyle.isValid(style)) { + throw new WrongReadingPreferenceRequestException(); + } + } + } + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/Roles.java b/src/main/java/BookPick/mvp/domain/auth/Roles.java new file mode 100644 index 0000000..7f98e4e --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/Roles.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.auth; + +public enum Roles { + ROLE_USER("일반 사용자"), + ROLE_CURATOR("큐레이터"), + ROLE_OWNER("서점 주인"), + ROLE_ADMIN("운영자"); + + private final String description; + + Roles(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/controller/LoginController.java b/src/main/java/BookPick/mvp/domain/auth/controller/LoginController.java new file mode 100644 index 0000000..408c590 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/controller/LoginController.java @@ -0,0 +1,49 @@ +package BookPick.mvp.domain.auth.controller; + +import BookPick.mvp.domain.auth.service.LoginService; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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; +import BookPick.mvp.domain.auth.dto.LoginReq; +import BookPick.mvp.domain.auth.dto.LoginRes; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.RefreshTokenCookieManager; + +@RequestMapping("/api/v1/auth/login") +@RequiredArgsConstructor +@RestController +public class LoginController { + + private final LoginService loginService; + private final RefreshTokenCookieManager refreshTokenCookieManager; + + + @PostMapping + @Operation(summary = "로그인", description = "로그인", tags = {"Auth"}) + public ResponseEntity> login( + @RequestBody @Valid LoginReq req, + HttpServletRequest servletRequest, + HttpServletResponse servletResponse) { + + // 1. 액세스 토큰 포함 DTO 생성 + LoginRes res = loginService.login(req, servletRequest); + + // 2. 리프레시 토큰 포함 + refreshTokenCookieManager.addRefreshTokenCookie(servletResponse, res.refreshToken()); + + // 3. Login response Dto에서 Refresh token 빼기 + LoginRes result = LoginRes.fromWithoutRefreshToken(res, res.accessToken()); + return ResponseEntity + .ok(ApiResponse.success(SuccessCode.LOGIN_SUCCESS, result)); + } + + +} diff --git a/src/main/java/BookPick/mvp/domain/auth/controller/LogoutController.java b/src/main/java/BookPick/mvp/domain/auth/controller/LogoutController.java new file mode 100644 index 0000000..118ba05 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/controller/LogoutController.java @@ -0,0 +1,33 @@ +package BookPick.mvp.domain.auth.controller; + +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import BookPick.mvp.domain.auth.service.LogoutService; +import BookPick.mvp.domain.auth.service.CustomUserDetails; + +@RestController +@RequestMapping("/api/v1/auth/logout") +@RequiredArgsConstructor +public class LogoutController { + + private final LogoutService logoutService; + + @PostMapping + @Operation(summary = "로그아웃", description = "로그아웃", tags = {"Auth"}) + public ResponseEntity> logout(HttpServletRequest request, HttpServletResponse response, + @AuthenticationPrincipal CustomUserDetails currentUser) { + + logoutService.logout(currentUser, request, response); + + return ResponseEntity.ok(ApiResponse.success(SuccessCode.LOGOUT_SUCCESS, null)); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/controller/SignUpController.java b/src/main/java/BookPick/mvp/domain/auth/controller/SignUpController.java new file mode 100644 index 0000000..6be591f --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/controller/SignUpController.java @@ -0,0 +1,35 @@ +package BookPick.mvp.domain.auth.controller; + + +import BookPick.mvp.domain.auth.dto.SignReq; +import BookPick.mvp.domain.auth.dto.SignRes; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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; +import BookPick.mvp.domain.auth.service.SignUpService; + +@RequiredArgsConstructor +@RestController // v1, v2 같은 버전은 추후 버전 관리를 위해 필요한 것인데 해당 프로젝트는 학습용 이므로 추후에 유지 보수 예정 X -> 따라서 버전 명 명시 안할 예정 +@RequestMapping("/api/v1/auth/signup") +public class SignUpController { + private final SignUpService authService; + + @PostMapping + @Operation(summary = "회원가입", description = "회원가입", tags = {"Auth"}) + public ResponseEntity> signUp(@RequestBody @Valid SignReq req, HttpServletResponse servletRes) { + SignRes res = authService.signUp(req); //data 얻기 + + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.REGISTER_SUCCESS, res)); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/auth/controller/TokenRefreshController.java b/src/main/java/BookPick/mvp/domain/auth/controller/TokenRefreshController.java new file mode 100644 index 0000000..5ace22a --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/controller/TokenRefreshController.java @@ -0,0 +1,51 @@ +package BookPick.mvp.domain.auth.controller; + +import BookPick.mvp.domain.auth.dto.LoginRes; +import BookPick.mvp.domain.auth.service.TokenRefreshService; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.RefreshTokenCookieManager; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import BookPick.mvp.domain.auth.service.CustomUserDetails; + +@RestController +@RequestMapping("/api/v1/auth/token") +@RequiredArgsConstructor +public class TokenRefreshController { + + private final RefreshTokenCookieManager refreshTokenCookieManager; + private final TokenRefreshService tokenRefreshService; + + /** + * 🔄 Refresh Token을 이용해 Access Token 재발급 + */ + @PostMapping("/refresh") + @Operation(summary = "액세스 토큰 재발급", description = "액세스 토큰 재발급", tags = {"Auth"}) + public ResponseEntity> refreshAccessToken( + HttpServletRequest request, + HttpServletResponse response, + @AuthenticationPrincipal CustomUserDetails currentUser + ) { + + // 1️⃣ 쿠키에서 refresh token 추출 + String refreshToken = refreshTokenCookieManager.getRefreshTokenFromCookie(request); + + // 2️⃣ 새 access + refresh token 생성 + LoginRes newTokens = tokenRefreshService.refreshTokens(currentUser, refreshToken); + + // 3️⃣ 새 refresh token을 쿠키에 다시 설정 + refreshTokenCookieManager.addRefreshTokenCookie(response, newTokens.refreshToken()); + + // 4️⃣ refresh token은 response에 포함하지 않음 + LoginRes result = LoginRes.fromWithoutRefreshToken(newTokens, newTokens.accessToken()); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.TOKEN_REFERSH_SUCCESS, result)); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/dto/LoginReq.java b/src/main/java/BookPick/mvp/domain/auth/dto/LoginReq.java new file mode 100644 index 0000000..39a8260 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/dto/LoginReq.java @@ -0,0 +1,12 @@ +package BookPick.mvp.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +// -- Login -- +public record LoginReq( + @NotBlank @Email String email, + @Size(min = 8, max = 72) String password +) { +} diff --git a/src/main/java/BookPick/mvp/domain/auth/dto/LoginRes.java b/src/main/java/BookPick/mvp/domain/auth/dto/LoginRes.java new file mode 100644 index 0000000..f9deb3e --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/dto/LoginRes.java @@ -0,0 +1,41 @@ +package BookPick.mvp.domain.auth.dto; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; + +public record LoginRes( + long userId, + String email, + String nickname, + String bio, + String profileImageUrl, + boolean isFirstLogin, + + String accessToken, + String refreshToken + +) { + public static LoginRes from(CustomUserDetails customUserDetails, String accessToken, String refreshToken) { + return new LoginRes( + customUserDetails.getId(), + customUserDetails.getUsername(), // username = email + customUserDetails.getNickname(), + customUserDetails.getBio(), + customUserDetails.getProfileImageUrl(), + customUserDetails.isFirstLogin(), + accessToken, + refreshToken + ); + } + public static LoginRes fromWithoutRefreshToken(LoginRes res ,String accessToken) { + return new LoginRes( + res.userId(), + res.email(), + res.nickname(), + res.bio(), + res.profileImageUrl(), + res.isFirstLogin(), + accessToken, + null + ); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/dto/SignReq.java b/src/main/java/BookPick/mvp/domain/auth/dto/SignReq.java new file mode 100644 index 0000000..5044d3a --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/dto/SignReq.java @@ -0,0 +1,12 @@ +package BookPick.mvp.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +// -- SignUp -- +public record SignReq( + @NotBlank @Email String email, + @Size(min = 8, max = 72) String password +) { +} diff --git a/src/main/java/BookPick/mvp/domain/auth/dto/SignRes.java b/src/main/java/BookPick/mvp/domain/auth/dto/SignRes.java new file mode 100644 index 0000000..1237d61 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/dto/SignRes.java @@ -0,0 +1,9 @@ +package BookPick.mvp.domain.auth.dto; + +public record SignRes( + long userId +) { + public static SignRes from(long userId) { + return new SignRes(userId); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/exception/DuplicateEmailException.java b/src/main/java/BookPick/mvp/domain/auth/exception/DuplicateEmailException.java new file mode 100644 index 0000000..9e16355 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/exception/DuplicateEmailException.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.auth.exception; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class DuplicateEmailException extends BusinessException { + public DuplicateEmailException() { + super(ErrorCode.DUPLICATE_EMAIL); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/exception/InvalidLoginException.java b/src/main/java/BookPick/mvp/domain/auth/exception/InvalidLoginException.java new file mode 100644 index 0000000..c5fa443 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/exception/InvalidLoginException.java @@ -0,0 +1,12 @@ +package BookPick.mvp.domain.auth.exception; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + + +// 401 +public class InvalidLoginException extends BusinessException { + public InvalidLoginException() { + super(ErrorCode.AUTHENTICATION_FAILED); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/exception/InvalidTokenTypeException.java b/src/main/java/BookPick/mvp/domain/auth/exception/InvalidTokenTypeException.java new file mode 100644 index 0000000..6cb5f78 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/exception/InvalidTokenTypeException.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.auth.exception; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class InvalidTokenTypeException extends BusinessException { + public InvalidTokenTypeException(){ + super(ErrorCode.Invalid_Token_Type); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/exception/JwtTokenExpiredException.java b/src/main/java/BookPick/mvp/domain/auth/exception/JwtTokenExpiredException.java new file mode 100644 index 0000000..77e1084 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/exception/JwtTokenExpiredException.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.auth.exception; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class JwtTokenExpiredException extends BusinessException { + public JwtTokenExpiredException(){ + super(ErrorCode.Token_Expired); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/exception/NotAuthenticateUser.java b/src/main/java/BookPick/mvp/domain/auth/exception/NotAuthenticateUser.java new file mode 100644 index 0000000..8f17a13 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/exception/NotAuthenticateUser.java @@ -0,0 +1,11 @@ +package BookPick.mvp.domain.auth.exception; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class NotAuthenticateUser extends BusinessException { + public NotAuthenticateUser() { + super(ErrorCode.UNAUTHORIZED); + } + +} diff --git a/src/main/java/BookPick/mvp/domain/auth/service/CustomUserDetails.java b/src/main/java/BookPick/mvp/domain/auth/service/CustomUserDetails.java new file mode 100644 index 0000000..2618024 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/service/CustomUserDetails.java @@ -0,0 +1,40 @@ +package BookPick.mvp.domain.auth.service; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; + +import java.util.Collection; + +@Getter +@Setter +public class CustomUserDetails extends User { + private Long id; + private String nickname; + private String bio; + private String profileImageUrl; + private boolean isFirstLogin; + + public CustomUserDetails( + BookPick.mvp.domain.user.entity.User user, + Collection authorities + ) { + super(user.getEmail(), user.getPassword(), authorities); + this.bio = user.getBio(); + this.profileImageUrl = user.getProfileImageUrl(); + } + + public CustomUserDetails( + Long userId, + String email, + Collection authorities + ) { + super(email, "", authorities); + this.id = userId; + } + + static public CustomUserDetails fromJwt(Long userId, String email, Collection authorities) { + return new CustomUserDetails(userId, email, authorities); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/service/LoginService.java b/src/main/java/BookPick/mvp/domain/auth/service/LoginService.java new file mode 100644 index 0000000..c448bec --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/service/LoginService.java @@ -0,0 +1,63 @@ +package BookPick.mvp.domain.auth.service; + +import BookPick.mvp.domain.auth.dto.LoginReq; +import BookPick.mvp.domain.auth.dto.LoginRes; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import BookPick.mvp.domain.auth.exception.InvalidLoginException; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.JwtAuthManager; + + +@Service +@RequiredArgsConstructor +public class LoginService { + private final JwtAuthManager jwtAuthManager; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final UserRepository userRepository; + + + // 1. jwt 기반 로그인 + @Transactional + public LoginRes login(LoginReq req, HttpServletRequest servletReq) throws RuntimeException { + + // 1. 토큰 생성 + var token = new UsernamePasswordAuthenticationToken(req.email(), req.password()); + + // 2. 로그인 검증 + try { + + // 아이디 및 패스워드 확인 + var auth = authenticationManagerBuilder.getObject().authenticate(token); + + // Jwt 토큰 생성 + JwtAuthManager.TokenPair tokenPair = jwtAuthManager.createTokens(auth); + + // + LoginRes res = LoginRes.from((CustomUserDetails) auth.getPrincipal(), tokenPair.accessToken(), tokenPair.refreshToken()); + + if(res.isFirstLogin()){ + User user = userRepository.findById(res.userId()) + .orElseThrow(UserNotFoundException::new); + + user.setFirstLogin(false); + } + return res; + + } catch (BadCredentialsException | UsernameNotFoundException e) { + throw new InvalidLoginException(); + } catch (AuthenticationException e) { + throw new InvalidLoginException(); + } + } +} + diff --git a/src/main/java/BookPick/mvp/domain/auth/service/LogoutService.java b/src/main/java/BookPick/mvp/domain/auth/service/LogoutService.java new file mode 100644 index 0000000..5fa7870 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/service/LogoutService.java @@ -0,0 +1,94 @@ +package BookPick.mvp.domain.auth.service; + +import BookPick.mvp.domain.auth.exception.NotAuthenticateUser; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.TokenBlacklistManager; +import BookPick.mvp.global.util.JwtUtil; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import BookPick.mvp.domain.auth.exception.JwtTokenExpiredException; + +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +public class LogoutService { + + private final JwtUtil jwtUtil; + private final TokenBlacklistManager tokenBlacklistManager; + + + public void logout(CustomUserDetails currentUser, HttpServletRequest request, HttpServletResponse response) { + + + // 1. 인증 상태 검증 + if (currentUser == null) { + throw new NotAuthenticateUser(); + } + + + // 2. 쿠키 검증 + Cookie[] cookies = request.getCookies(); + if (cookies == null) return; + + + // 3. 리프레시 토큰 검증 + for (Cookie cookie : cookies) { + if ("refreshToken".equals(cookie.getName())) { + + // 3.1 리프레시 토큰 획득 + String refreshToken = cookie.getValue(); + + if (refreshToken == null || refreshToken.isEmpty()) return; + + + int a=1; + // 3.2 클레임 토큰 획득 + Claims claims; + int b=1; + + try { + claims = jwtUtil.extractRefreshToken(refreshToken); + } catch (Exception e) { + throw new JwtTokenExpiredException(); + } + + // 3.3 토큰의 userId와 현재 인증된 user 비교 + Long tokenUserId; + try { + tokenUserId = claims.get("userId", Number.class).longValue(); + } catch (Exception e) { + throw new AccessDeniedException("Invalid token structure"); + } + + + // 3.4 jti 및 만료시간 계산 후 블랙리스트 추가 + String jti = claims.getId(); + long expMillis = claims.getExpiration().getTime() - System.currentTimeMillis(); + if (jti != null && expMillis > 0) { + tokenBlacklistManager.add(jti, Instant.now().plusMillis(expMillis)); + } + + + // 3.5 클라이언트 쿠키 제거 + Cookie del = new Cookie("refreshToken", null); + del.setHttpOnly(true); + del.setSecure(true); + del.setPath("/"); + del.setMaxAge(0); + response.addCookie(del); + + + // 3.6 쿠키 더 순회하지 않고 종료 + break; + } + } + } +} + diff --git a/src/main/java/BookPick/mvp/domain/auth/service/MyUserDetailsService.java b/src/main/java/BookPick/mvp/domain/auth/service/MyUserDetailsService.java new file mode 100644 index 0000000..c1ba48f --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/service/MyUserDetailsService.java @@ -0,0 +1,54 @@ +package BookPick.mvp.domain.auth.service; + + +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + + +// 스프링 시큐리티에서 자동으로 호출 + +@Service +@RequiredArgsConstructor +public class MyUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + List auth = new ArrayList<>(); + + + // 유저 찾고 + BookPick.mvp.domain.user.entity.User user = userRepository.findByEmail(email) + .orElseThrow(UserNotFoundException::new); + + + // 역할 부여하고 + if (user.getRole().equals(Roles.ROLE_USER)) { + auth.add(new SimpleGrantedAuthority(Roles.ROLE_USER.name())); + } + + // 프로바이더가 토큰으로 받은 비밀번호와 비교할 DB에서 조회한 User객체 반환 -> 해당 유저 객체의 비밀번호를 프로바이더가 사용한다. + CustomUserDetails customUserDetails = new CustomUserDetails(user, auth); // email, password, authorities 등록 + customUserDetails.setId(user.getId()); + customUserDetails.setNickname(user.getNickname()); + customUserDetails.setBio(user.getBio()); + customUserDetails.setProfileImageUrl(user.getProfileImageUrl()); + customUserDetails.setFirstLogin(user.isFirstLogin()); + + return customUserDetails; + + } + +} + diff --git a/src/main/java/BookPick/mvp/domain/auth/service/SignUpService.java b/src/main/java/BookPick/mvp/domain/auth/service/SignUpService.java new file mode 100644 index 0000000..2a6e868 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/service/SignUpService.java @@ -0,0 +1,69 @@ +package BookPick.mvp.domain.auth.service; + + +import BookPick.mvp.domain.ReadingPreference.service.ReadingPreferenceService; +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.auth.dto.SignReq; +import BookPick.mvp.domain.auth.dto.SignRes; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import BookPick.mvp.domain.auth.exception.DuplicateEmailException; +import BookPick.mvp.domain.auth.util.Manager.signup.SignUpManager; + +import java.time.LocalDateTime; + + +@Service +@RequiredArgsConstructor +public class SignUpService { //Dto로 컨트롤러에서 받음 + + private final UserRepository userRepo; + private final PasswordEncoder passwordEncoder; + private final SignUpManager signUpManager; + + private final ReadingPreferenceService readingPreferenceService; + + public SignRes signUp(SignReq req) throws RuntimeException { + + + // 1. 중복 확인 + if (userRepo.existsByEmail(req.email())) { + throw new DuplicateEmailException(); + } + + + Roles userRole = Roles.ROLE_USER; + + if (signUpManager.isAdmin( req.email())){ + userRole=Roles.ROLE_ADMIN; + } + + User user = User.builder() + .email(req.email()) + .password(passwordEncoder.encode(req.password())) + .nickname(null) + .profileImageUrl(null) + .role(userRole) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + + // 3. DB에 저장 + User savedUSer = userRepo.save(user); + + // 4. 빈 독서취향 생성 + readingPreferenceService.addClearReadingPreference(savedUSer.getId()); + + // 5. Sign Response DTO 반환 + return new SignRes(savedUSer.getId()); + + } + + + +} + diff --git a/src/main/java/BookPick/mvp/domain/auth/service/TokenRefreshService.java b/src/main/java/BookPick/mvp/domain/auth/service/TokenRefreshService.java new file mode 100644 index 0000000..2696080 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/service/TokenRefreshService.java @@ -0,0 +1,58 @@ +package BookPick.mvp.domain.auth.service; + +import BookPick.mvp.domain.auth.dto.LoginRes; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.JwtAuthManager; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.TokenBlacklistManager; +import BookPick.mvp.global.util.JwtUtil; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TokenRefreshService { + + private final JwtAuthManager jwtAuthManager; + private final TokenBlacklistManager tokenBlacklistManager; + private final JwtUtil jwtUtil; + + public LoginRes refreshTokens(CustomUserDetails customUserDetails, String refreshToken) { + + if (refreshToken == null || refreshToken.isBlank()) { + throw new RuntimeException("리프레시 토큰이 없습니다."); + } + + // 🔒 블랙리스트 확인 + if (tokenBlacklistManager.isBlacklisted(refreshToken)) { + throw new RuntimeException("유효하지 않은 리프레시 토큰입니다."); + } + + // ✅ 토큰 유효성 검증 + if (!jwtUtil.validateToken(refreshToken, false)) { + throw new RuntimeException("리프레시 토큰이 만료되었거나 유효하지 않습니다."); + } + + // Claims 추출 + Claims claims = jwtUtil.extractRefreshToken(refreshToken); + Double userIdDouble = claims.get("userId", Double.class); + Long userId = userIdDouble.longValue(); // Double → Long + + if (!customUserDetails.getId().equals(userId)) { + throw new RuntimeException("토큰 사용자 정보와 일치하지 않습니다."); + } + + // Authentication 객체 생성 + Authentication auth = new UsernamePasswordAuthenticationToken( + customUserDetails, + null, + customUserDetails.getAuthorities() + ); + + // 새 토큰 발급 + JwtAuthManager.TokenPair tokenPair = jwtAuthManager.createTokens(auth); + + return LoginRes.from(customUserDetails, tokenPair.accessToken(), tokenPair.refreshToken()); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/util/Manager/login/jwt/JwtAuthManager.java b/src/main/java/BookPick/mvp/domain/auth/util/Manager/login/jwt/JwtAuthManager.java new file mode 100644 index 0000000..c1c2b58 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/util/Manager/login/jwt/JwtAuthManager.java @@ -0,0 +1,28 @@ +package BookPick.mvp.domain.auth.util.Manager.login.jwt; + +import BookPick.mvp.global.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class JwtAuthManager { + private final JwtUtil jwtUtil; + + + // 1. 토큰 생성 + public TokenPair createTokens(Authentication token){ + String accessToken = jwtUtil.createAccessToken(token); + String refreshToken = jwtUtil.createRefreshToken(token); + + return TokenPair.from(accessToken, refreshToken); + } + + public record TokenPair(String accessToken, String refreshToken) { + public static TokenPair from(String accessToken, String refreshToken){ + return new TokenPair(accessToken, refreshToken); + } + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/auth/util/Manager/login/jwt/RefreshTokenCookieManager.java b/src/main/java/BookPick/mvp/domain/auth/util/Manager/login/jwt/RefreshTokenCookieManager.java new file mode 100644 index 0000000..2c6763f --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/util/Manager/login/jwt/RefreshTokenCookieManager.java @@ -0,0 +1,52 @@ +package BookPick.mvp.domain.auth.util.Manager.login.jwt; + + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; + +@Component +public class RefreshTokenCookieManager { + + + private static final String COOKIE_NAME = "refreshToken"; + private static final int MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7일 + + /** + * HttpOnly, Secure 쿠키로 리프레시 토큰 세팅 + */ + public void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + Cookie refreshCookie = new Cookie(COOKIE_NAME, refreshToken); + refreshCookie.setHttpOnly(true); // JS에서 접근 불가 + refreshCookie.setSecure(true); // HTTPS 환경에서만 전송 + refreshCookie.setPath("/"); // 전체 경로에서 사용 + refreshCookie.setMaxAge(MAX_AGE_SECONDS); + response.addCookie(refreshCookie); + } + + /** + * 로그아웃 시 쿠키 제거 + */ + public void clearRefreshTokenCookie(HttpServletResponse response) { + Cookie refreshCookie = new Cookie(COOKIE_NAME, null); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(0); // 즉시 삭제 + response.addCookie(refreshCookie); + } + + + public String getRefreshTokenFromCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + + for (Cookie cookie : cookies) { + if (COOKIE_NAME.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; // 없으면 null 반환 + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/util/Manager/login/jwt/TokenBlacklistManager.java b/src/main/java/BookPick/mvp/domain/auth/util/Manager/login/jwt/TokenBlacklistManager.java new file mode 100644 index 0000000..2dea09c --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/util/Manager/login/jwt/TokenBlacklistManager.java @@ -0,0 +1,37 @@ +package BookPick.mvp.domain.auth.util.Manager.login.jwt; + +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class TokenBlacklistManager { + + // key: JWT ID (jti), value: 만료 시간 + private final Map blacklist = new ConcurrentHashMap<>(); + + // 블랙리스트 등록 + public void add(String jti, Instant expiration) { + blacklist.put(jti, expiration); + } + + // 블랙리스트 체크 + public boolean isBlacklisted(String jti) { + Instant exp = blacklist.get(jti); + if (exp == null) return false; + + // 이미 만료된 토큰은 제거 + if (Instant.now().isAfter(exp)) { + blacklist.remove(jti); + return false; + } + return true; + } + + // 테스트용: 전체 제거 + public void clear() { + blacklist.clear(); + } +} diff --git a/src/main/java/BookPick/mvp/domain/auth/util/Manager/signup/SignUpManager.java b/src/main/java/BookPick/mvp/domain/auth/util/Manager/signup/SignUpManager.java new file mode 100644 index 0000000..768a95d --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/auth/util/Manager/signup/SignUpManager.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.auth.util.Manager.signup; + +import org.springframework.stereotype.Component; + +@Component +public class SignUpManager { + + private String amdinEmail="admin@admin.com"; + + public boolean isAdmin(Object object){ + if(object.equals(amdinEmail)){ + return true; + } + return false; + } + + +} diff --git a/src/main/java/BookPick/mvp/domain/author/dto/preference/AuthorDto.java b/src/main/java/BookPick/mvp/domain/author/dto/preference/AuthorDto.java new file mode 100644 index 0000000..c2409fb --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/author/dto/preference/AuthorDto.java @@ -0,0 +1,9 @@ +package BookPick.mvp.domain.author.dto.preference; + +import java.time.LocalDateTime; + +public record AuthorDto( + String name // 요청/응답용: 작가 이름 +) { + +} diff --git a/src/main/java/BookPick/mvp/domain/author/entity/Author.java b/src/main/java/BookPick/mvp/domain/author/entity/Author.java new file mode 100644 index 0000000..40ca320 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/author/entity/Author.java @@ -0,0 +1,54 @@ +package BookPick.mvp.domain.author.entity; + +import BookPick.mvp.domain.author.dto.preference.AuthorDto; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import org.springframework.context.annotation.Bean; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Entity +@Builder +@AllArgsConstructor +@Getter +@Setter +public class Author { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private Integer curated_count; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + private LocalDateTime deletedAt; + + public Author() { + + } + + public static Author from(AuthorDto dto) { + return Author.builder() + .name(dto.name()) + .curated_count(0) // 초기값 설정 + .createdAt(null) + .updatedAt(null) + .deletedAt(null) + .build(); + } + + +} diff --git a/src/main/java/BookPick/mvp/domain/author/enums/AuthroErrorCode.java b/src/main/java/BookPick/mvp/domain/author/enums/AuthroErrorCode.java new file mode 100644 index 0000000..50f1cff --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/author/enums/AuthroErrorCode.java @@ -0,0 +1,19 @@ +package BookPick.mvp.domain.author.enums; + +import BookPick.mvp.global.enums.ErrorCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum AuthroErrorCode implements ErrorCodeInterface { + + + BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 책을 찾을 수 없습니다."); + + + + private final HttpStatus status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/author/exceptions/BookNotFoundException.java b/src/main/java/BookPick/mvp/domain/author/exceptions/BookNotFoundException.java new file mode 100644 index 0000000..05a7cbb --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/author/exceptions/BookNotFoundException.java @@ -0,0 +1,11 @@ +package BookPick.mvp.domain.author.exceptions; + +import BookPick.mvp.domain.author.enums.AuthroErrorCode; +import BookPick.mvp.domain.book.dto.preference.BookDto; +import BookPick.mvp.global.exception.BusinessException; + +public class BookNotFoundException extends BusinessException { + public BookNotFoundException(){ + super(AuthroErrorCode.BOOK_NOT_FOUND); + } +} diff --git a/src/main/java/BookPick/mvp/domain/author/repository/AuthorRepository.java b/src/main/java/BookPick/mvp/domain/author/repository/AuthorRepository.java new file mode 100644 index 0000000..14a135f --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/author/repository/AuthorRepository.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.author.repository; + + +// 1. 작가 레포가 왜 필요할까? +// 2. 추후 이러한 작가들이 많은 사용자들이 찾더라 저장 필요 + +import BookPick.mvp.domain.author.entity.Author; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface AuthorRepository extends JpaRepository { + + + Optional findByName(String name); +} diff --git a/src/main/java/BookPick/mvp/domain/author/service/AuthorSaveService.java b/src/main/java/BookPick/mvp/domain/author/service/AuthorSaveService.java new file mode 100644 index 0000000..ab38e61 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/author/service/AuthorSaveService.java @@ -0,0 +1,67 @@ +package BookPick.mvp.domain.author.service; + +import BookPick.mvp.domain.author.dto.preference.AuthorDto; +import BookPick.mvp.domain.author.entity.Author; +import BookPick.mvp.domain.author.repository.AuthorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + + +@Service +@RequiredArgsConstructor +public class AuthorSaveService { + private final AuthorRepository authorRepository; + + // 1. Author 리스트 + public void saveAuthorIfNotExists(Set authors) { + for (Author author : authors) { + saveAuthorIfNotExists(author); // 단건 메서드 재사용 + } + } + + // 2. Author 저장 + public void saveAuthorIfNotExists(Author author) { + authorRepository.findByName(author.getName()) + .orElseGet(() -> authorRepository.save(author)); + } + // 3. String 리스트 + public Set saveAuthorsIfNotExistsByName(Set authorNames) { + Set authors= new HashSet<>(); + + for (String name : authorNames) { + authors.add( saveAuthorIfNotExistsByName(name)); // 단건 메서드 재사용 + } + + return authors; + } + + // 4. String 단건 + public Author saveAuthorIfNotExistsByName(String name) { + return authorRepository.findByName(name) + .orElseGet(() -> authorRepository.save(new Author(null, name, 0, LocalDateTime.now(), null, null))); + } + + // 5.AuthorDto 리스트 + public Set saveAuthorIfNotExistsDto(Set authorDtos) { + Set authors = new HashSet<>(); + if(authorDtos != null) { + for (AuthorDto dto : authorDtos) { + authors.add(saveAuthorIfNotExistsDto(dto)); + } + } + + return authors; + } + + // 6.AuthorDto 단건 + public Author saveAuthorIfNotExistsDto(AuthorDto dto) { + return authorRepository.findByName(dto.name()) + .orElseGet(() -> authorRepository.save(new Author(null, dto.name(), 0, null, null, null))); + } +} diff --git a/src/main/java/BookPick/mvp/domain/book/Controller/BookSearchController.java b/src/main/java/BookPick/mvp/domain/book/Controller/BookSearchController.java new file mode 100644 index 0000000..185a8ce --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/Controller/BookSearchController.java @@ -0,0 +1,42 @@ +package BookPick.mvp.domain.book.Controller; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.book.dto.search.BookSearchPageRes; +import BookPick.mvp.domain.book.dto.search.BookSearchReq; +import BookPick.mvp.domain.book.util.kakaoApi.BookSearchService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/book") +@RequiredArgsConstructor +public class BookSearchController { + + private final BookSearchService bookSearchService; + private final CurrentUserCheck currentUserCheck; + + @Operation(summary = "책 검색", description = "검색어로 책 목록 조회", tags = {"Book Search"}) + @PostMapping("/search") + public ResponseEntity> searchBookList(@RequestBody BookSearchReq req, + @AuthenticationPrincipal CustomUserDetails currentUser + ) { + BookSearchPageRes res = bookSearchService.getBookSearchList(req); + currentUserCheck.validateLoginUser(currentUser); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.BOOK_LIST_READ_SUCCESS, res)); + } + + + +} diff --git a/src/main/java/BookPick/mvp/domain/book/dto/preference/BookDto.java b/src/main/java/BookPick/mvp/domain/book/dto/preference/BookDto.java new file mode 100644 index 0000000..768dca7 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/dto/preference/BookDto.java @@ -0,0 +1,15 @@ +package BookPick.mvp.domain.book.dto.preference; + +import BookPick.mvp.domain.book.entity.Book; + +import java.util.List; +import java.util.Set; + +public record BookDto( + String title, + String author, + String image, + String isbn +) { + +} diff --git a/src/main/java/BookPick/mvp/domain/book/dto/preference/BookRes.java b/src/main/java/BookPick/mvp/domain/book/dto/preference/BookRes.java new file mode 100644 index 0000000..53ff5be --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/dto/preference/BookRes.java @@ -0,0 +1,13 @@ +package BookPick.mvp.domain.book.dto.preference; + +public record BookRes( + String title, + String author, + String image, + String isbn +) { + + public static BookRes from(String title, String author, String image, String isbn) { + return new BookRes(title, author, image, isbn); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/book/dto/search/BookSearchPageRes.java b/src/main/java/BookPick/mvp/domain/book/dto/search/BookSearchPageRes.java new file mode 100644 index 0000000..0a5d45b --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/dto/search/BookSearchPageRes.java @@ -0,0 +1,11 @@ +package BookPick.mvp.domain.book.dto.search; + +import BookPick.mvp.global.dto.PageInfo; + +import java.util.List; + +public record BookSearchPageRes( + List books, + PageInfo pageInfo +) { +} diff --git a/src/main/java/BookPick/mvp/domain/book/dto/search/BookSearchReq.java b/src/main/java/BookPick/mvp/domain/book/dto/search/BookSearchReq.java new file mode 100644 index 0000000..86f31b1 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/dto/search/BookSearchReq.java @@ -0,0 +1,8 @@ +package BookPick.mvp.domain.book.dto.search; + +// -- R -- +public record BookSearchReq( + String keyword, + Integer page +) { +} diff --git a/src/main/java/BookPick/mvp/domain/book/dto/search/BookSearchRes.java b/src/main/java/BookPick/mvp/domain/book/dto/search/BookSearchRes.java new file mode 100644 index 0000000..619eb10 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/dto/search/BookSearchRes.java @@ -0,0 +1,9 @@ +package BookPick.mvp.domain.book.dto.search; + +public record BookSearchRes( + String title, + String author, + String imageUrl, + String isbn +) { +} diff --git a/src/main/java/BookPick/mvp/domain/book/entity/Book.java b/src/main/java/BookPick/mvp/domain/book/entity/Book.java new file mode 100644 index 0000000..28b0e49 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/entity/Book.java @@ -0,0 +1,53 @@ +package BookPick.mvp.domain.book.entity; + +import BookPick.mvp.domain.author.entity.Author; +import BookPick.mvp.domain.book.dto.preference.BookDto; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Builder +@AllArgsConstructor +@Getter +@Setter +public class Book { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + @JsonIgnore // Todo 1. 큐레이션에 작가의 대한 정보가 필요 없기 때문에 null -> 추후 디벨렆 예정 + private Author author; // 하나의 책마다 여러 작가들 존재 + + private String image; + + private String isbn; + + public Book() { + + } + + + public static Book from(BookDto bookDto, Author author) { + + + return Book.builder() + .title(bookDto.title()) + .author(author) + .image(bookDto.image()) + .isbn(bookDto.isbn()) + .build(); + } +} diff --git a/src/main/java/BookPick/mvp/domain/book/repository/BookRepository.java b/src/main/java/BookPick/mvp/domain/book/repository/BookRepository.java new file mode 100644 index 0000000..0c15576 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/repository/BookRepository.java @@ -0,0 +1,19 @@ +package BookPick.mvp.domain.book.repository; + + +// 1. 책 레포 필요한 이유 +// 2. 추후 어떠한 책들이 인기가 있었는지 체크하기 위해 +// 3. 큐레이션 작성된 책들 count 필요 + +import BookPick.mvp.domain.book.entity.Book; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface BookRepository extends JpaRepository { + + + Optional findByTitle(String title); +} diff --git a/src/main/java/BookPick/mvp/domain/book/service/BookSaveService.java b/src/main/java/BookPick/mvp/domain/book/service/BookSaveService.java new file mode 100644 index 0000000..010384d --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/service/BookSaveService.java @@ -0,0 +1,70 @@ +package BookPick.mvp.domain.book.service; + +import BookPick.mvp.domain.author.entity.Author; +import BookPick.mvp.domain.author.repository.AuthorRepository; +import BookPick.mvp.domain.author.service.AuthorSaveService; +import BookPick.mvp.domain.book.dto.preference.BookDto; +import BookPick.mvp.domain.book.entity.Book; +import BookPick.mvp.domain.book.repository.BookRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class BookSaveService { + + private final BookRepository bookRepository; + private final AuthorSaveService authorSaveService; + + // 1. Book 리스트 + public void saveBookIfNotExists(Set books) { + + for (Book book : books) { + saveBookIfNotExists(book); // 단건 재사용 + } + } + + // 2. Book 단건 + public void saveBookIfNotExists(Book book) { + bookRepository.findByTitle(book.getTitle()) + .orElseGet(() -> { + authorSaveService.saveAuthorIfNotExists(book.getAuthor()); // 작가 먼저 저장 + return bookRepository.save(book); + }); + } + + + // ------------------------위에거 안쓰임 + + // 3. BookDto 리스트 + public Set saveBookIfNotExistsDto(Set bookDtos) { + Set books= new HashSet<>(); + if(bookDtos != null) { // BookDto가 Null이 아닐 경우 책 저장 진행 + for (BookDto dto : bookDtos) { + books.add(saveBookIfNotExistsDto(dto)); + } + } + + return books; + } + + // 4. BookDto 단건 + public Book saveBookIfNotExistsDto(BookDto dto) { + + return bookRepository.findByTitle(dto.title()) + .orElseGet(() -> { // 책이 없으면 + // authors 저장 + Author author = authorSaveService.saveAuthorIfNotExistsByName(dto.author()); + // Book 객체 변환 후 저장 + Book book = Book.from(dto, author); + return bookRepository.save(book); + }); + } +} + + diff --git a/src/main/java/BookPick/mvp/domain/book/util/kakaoApi/BookSearchService.java b/src/main/java/BookPick/mvp/domain/book/util/kakaoApi/BookSearchService.java new file mode 100644 index 0000000..fb24c27 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/book/util/kakaoApi/BookSearchService.java @@ -0,0 +1,134 @@ +package BookPick.mvp.domain.book.util.kakaoApi; + +import BookPick.mvp.domain.book.dto.search.BookSearchPageRes; +import BookPick.mvp.domain.book.dto.search.BookSearchReq; +import BookPick.mvp.domain.book.dto.search.BookSearchRes; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.global.dto.PageInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class BookSearchService { + private final CurationRepository curationRepository; + + + @Value("${api.kakao.key}") + private String kakaoApiKey; + + private static final String API_URL = "https://dapi.kakao.com/v3/search/book"; + + public BookSearchPageRes getBookSearchList(BookSearchReq req) { + + RestTemplate restTemplate = new RestTemplate(); + + // 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "KakaoAK " + kakaoApiKey); + + // 요청 URL 구성 + UriComponents uri = UriComponentsBuilder.fromHttpUrl(API_URL) + .queryParam("query", req.keyword()) + .queryParam("page", req.page()) + .queryParam("size", 10) + .build(); + + + HttpEntity requestEntity = new HttpEntity<>(headers); + + // 카카오 API 호출 + ResponseEntity response = restTemplate.exchange( + uri.toUriString(), + HttpMethod.GET, + requestEntity, + Map.class + ); + + // documents 배열 추출 + List> documents = (List>) response.getBody().get("documents"); + + // 필요한 데이터만 변환 + List books = new ArrayList<>(); + for (Map doc : documents) { + String title = (String) doc.get("title"); + List authors = (List) doc.get("authors"); + String author = authors != null && !authors.isEmpty() ? authors.get(0) : "저자 미상"; + String image = (String) doc.get("thumbnail"); + String isbn = (String) doc.get("isbn"); + + books.add(new BookSearchRes(title, author, image, isbn)); + } + + // meta 데이터 추출 → PageInfo 변환 + Map meta = (Map) response.getBody().get("meta"); + int totalCount = ((Number) meta.get("total_count")).intValue(); + boolean isEnd = (boolean) meta.get("is_end"); + + // PageInfo 매핑 (현재 페이지는 Kakao API 요청 기준) + PageInfo pageInfo = new PageInfo( + req.page(), // currentPage (요청 page) + (int) Math.ceil((double) totalCount / 10), // totalPages (총 페이지 수) + totalCount, // totalElements (총 아이템 수) + !isEnd // hasNext (다음 페이지 여부) + ); + + // 최종 응답 DTO 반환 + return new BookSearchPageRes(books, pageInfo); + } + + public String getBookPurchaseLink(Long curationId) { + + // 큐레이션 조회 + Curation curation = curationRepository.findById(curationId) + .orElseThrow(CurationNotFoundException::new); + + RestTemplate restTemplate = new RestTemplate(); + + // 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "KakaoAK " + kakaoApiKey); + + // 요청 URL 구성 + UriComponents uri = UriComponentsBuilder.fromHttpUrl(API_URL) + .queryParam("query", curation.getBookTitle()) + .queryParam("page", 1) + .queryParam("size", 1) + .build(); + + HttpEntity requestEntity = new HttpEntity<>(headers); + + // 카카오 API 호출 + ResponseEntity response = restTemplate.exchange( + uri.toUriString(), + HttpMethod.GET, + requestEntity, + Map.class + ); + + // documents 배열 추출 + List> documents = (List>) response.getBody().get("documents"); + + // 첫 번째 결과의 URL 반환 + if (documents != null && !documents.isEmpty()) { + Map firstBook = documents.get(0); + return (String) firstBook.get("url"); + } + + return null; + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/controller/all/ReceivedCommentsController.java b/src/main/java/BookPick/mvp/domain/comment/controller/all/ReceivedCommentsController.java new file mode 100644 index 0000000..fb06eee --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/controller/all/ReceivedCommentsController.java @@ -0,0 +1,35 @@ +package BookPick.mvp.domain.comment.controller.all; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.comment.dto.read.ReceivedCommentsDTO; +import BookPick.mvp.domain.comment.service.ReceivedCommentsService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/comments") +@RequiredArgsConstructor +public class ReceivedCommentsController { + + private final ReceivedCommentsService receivedCommentsService; + private final CurrentUserCheck currentUserCheck; + + @Operation(summary = "받은 댓글 조회", description = "현재 사용자가 받은 최신 댓글 목록을 조회합니다", tags = {"Comment"}) + @GetMapping + public ResponseEntity> getReceivedComments( + @AuthenticationPrincipal CustomUserDetails currentUser) { + + currentUserCheck.validateLoginUser(currentUser); + + ReceivedCommentsDTO res = receivedCommentsService.receivedCommentsRead(currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_LIST_READ_SUCCESS, res)); + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/controller/base/CommentController.java b/src/main/java/BookPick/mvp/domain/comment/controller/base/CommentController.java new file mode 100644 index 0000000..e6b9f48 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/controller/base/CommentController.java @@ -0,0 +1,112 @@ +package BookPick.mvp.domain.comment.controller.base; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.comment.service.PagenationService; +import BookPick.mvp.domain.comment.dto.create.CommentCreateReq; +import BookPick.mvp.domain.comment.dto.create.CommentCreateRes; +import BookPick.mvp.domain.comment.dto.delete.CommentDeleteRes; +import BookPick.mvp.domain.comment.dto.read.CommentDetailRes; +import BookPick.mvp.domain.comment.dto.read.CommentListRes; +import BookPick.mvp.domain.comment.dto.update.CommentUpdateReq; +import BookPick.mvp.domain.comment.dto.update.CommentUpdateRes; +import BookPick.mvp.domain.comment.service.CommentService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/curations") +@RequiredArgsConstructor +public class CommentController { + private final CommentService commentService; + private final PagenationService pagenationService; + private final CurrentUserCheck currentUserCheck; + + // -- 1. 댓글 생성 -- + @Operation(summary = "댓글 생성", description = "특정 큐레이션에 댓글을 생성합니다", tags = {"Comment"}) + @PostMapping("/{curationId}/comments") + public ResponseEntity> create(@PathVariable Long curationId, + @RequestBody CommentCreateReq commentCreateReq, + @AuthenticationPrincipal CustomUserDetails currentUser) { + +// currentUserCheck.validateLoginUser(currentUser); + + CommentCreateRes res = commentService.createComment(currentUser.getId(), curationId, commentCreateReq); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(SuccessCode.COMMENT_CREATE_SUCCESS, res)); + } + + // -- 2. 댓글 리스트 조회 -- + @Operation(summary = "댓글 리스트 조회", description = "특정 큐레이션의 댓글 목록을 페이지네이션하여 조회합니다", tags = {"Comment"}) + @GetMapping("/{curationId}/comments") + public ResponseEntity> getCommentList(@PathVariable Long curationId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + + + // 1. 페이지를 1부터 보여주기 위해 -1 + page = page - 1; + + // 2. -1한 페이지가 0보다 작으면 0으로 + page = pagenationService.changeMinusPageToZeroPage(page); + CommentListRes res = commentService.getCommentList(curationId, page, size); + if (res.comments().isEmpty()) { + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_LIST_EMPTY, res)); + } + + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_LIST_READ_SUCCESS, res)); + } + + + // 2.1 댓글 상세 조회 + @Operation(summary = "댓글 상세 조회", description = "특정 댓글의 상세 정보를 조회합니다", tags = {"Comment"}) + @GetMapping("/{curationId}/comments/{commentId}") + public ResponseEntity> getCommentDetail(@PathVariable Long curationId, @PathVariable Long commentId) { + CommentDetailRes res = commentService.getCommentDetail(commentId); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_READ_SUCCESS, res)); + } + + + // -- 3. 댓글 수정 -- + @Operation(summary = "댓글 수정", description = "특정 댓글의 내용을 수정합니다", tags = {"Comment"}) + @PatchMapping("/{curationId}/comments/{commentId}") + public ResponseEntity> updateComment( + @PathVariable Long curationId, + @PathVariable Long commentId, + @RequestBody CommentUpdateReq req, + @AuthenticationPrincipal CustomUserDetails currentUser + ) { +// currentUserCheck.validateLoginUser(currentUser); + + CommentUpdateRes res = commentService.updateComment(currentUser.getId(), commentId, req); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_UPDATE_SUCCESS, res)); + } + + + // -- 4. 댓글 삭제 -- + @Operation(summary = "댓글 삭제", description = "특정 댓글을 삭제합니다 (자식 댓글도 함께 삭제됩니다)", tags = {"Comment"}) + @DeleteMapping("/{curationId}/comments/{commentId}") + public ResponseEntity> deleteComment( + @PathVariable Long curationId, + @PathVariable Long commentId + ) { + + + CommentDeleteRes res = commentService.deleteComment(curationId, commentId); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_DELETE_SUCCESS, res)); + } + + +} + + + + + diff --git a/src/main/java/BookPick/mvp/domain/comment/dto/create/CommentCreateReq.java b/src/main/java/BookPick/mvp/domain/comment/dto/create/CommentCreateReq.java new file mode 100644 index 0000000..24e171a --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/dto/create/CommentCreateReq.java @@ -0,0 +1,15 @@ +package BookPick.mvp.domain.comment.dto.create; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +// Todo. 각 리코드 분리 필요 +// -- C -- +public record CommentCreateReq( + Long parentId, + + @NotBlank(message = "댓글 내용은 비워둘 수 없습니다.") + @Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.") + String content +) { +} diff --git a/src/main/java/BookPick/mvp/domain/comment/dto/create/CommentCreateRes.java b/src/main/java/BookPick/mvp/domain/comment/dto/create/CommentCreateRes.java new file mode 100644 index 0000000..87551d8 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/dto/create/CommentCreateRes.java @@ -0,0 +1,13 @@ +package BookPick.mvp.domain.comment.dto.create; + +import BookPick.mvp.domain.comment.entity.Comment; + +public record CommentCreateRes( + Long commentId +) { + public static CommentCreateRes from(Comment comment) { + return new CommentCreateRes( + comment.getId() + ); + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/dto/delete/CommentDeleteRes.java b/src/main/java/BookPick/mvp/domain/comment/dto/delete/CommentDeleteRes.java new file mode 100644 index 0000000..4fa0ad9 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/dto/delete/CommentDeleteRes.java @@ -0,0 +1,13 @@ +package BookPick.mvp.domain.comment.dto.delete; + +import java.time.LocalDateTime; + +// -- D -- +public record CommentDeleteRes( + Long commentId, + LocalDateTime deletedAt +) { + public static CommentDeleteRes of(Long commentId, LocalDateTime deletedAt) { + return new CommentDeleteRes(commentId, deletedAt); + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/dto/read/CommentDetailRes.java b/src/main/java/BookPick/mvp/domain/comment/dto/read/CommentDetailRes.java new file mode 100644 index 0000000..c12bec3 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/dto/read/CommentDetailRes.java @@ -0,0 +1,31 @@ +package BookPick.mvp.domain.comment.dto.read; + +import BookPick.mvp.domain.comment.entity.Comment; + +import java.time.LocalDateTime; + +public record CommentDetailRes( + Long commentId, + Long parentId, + Long curationId, + Long userId, + String nickname, + String profileImageUrl, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static CommentDetailRes of(Comment comment) { + return new CommentDetailRes( + comment.getId(), + comment.getParent() != null ? comment.getParent().getId() : null, + comment.getCuration().getId(), + comment.getUser().getId(), + comment.getUser().getNickname(), + comment.getUser().getProfileImageUrl(), + comment.getContent(), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/dto/read/CommentListRes.java b/src/main/java/BookPick/mvp/domain/comment/dto/read/CommentListRes.java new file mode 100644 index 0000000..5dc10f5 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/dto/read/CommentListRes.java @@ -0,0 +1,41 @@ +package BookPick.mvp.domain.comment.dto.read; + +import BookPick.mvp.global.dto.PageInfo; + +import java.time.LocalDateTime; +import java.util.List; + +// -- R -- +public record CommentListRes( + List comments, + PageInfo pageInfo +) { + public static CommentListRes of(List comments, PageInfo pageInfo) { + return new CommentListRes(comments, pageInfo); + } + + // 📝 댓글 요약 DTO + public record CommentSummary( + Long commentId, + Long userId, + Long parentId, + String nickname, + String profileImageUrl, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static CommentSummary of( + Long commentId, + Long userId, + Long parentId, + String nickname, + String profileImageUrl, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + return new CommentSummary(commentId, userId, parentId, nickname, profileImageUrl, content, createdAt, updatedAt); + } + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/dto/read/ReceivedCommentsDTO.java b/src/main/java/BookPick/mvp/domain/comment/dto/read/ReceivedCommentsDTO.java new file mode 100644 index 0000000..c90d2c0 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/dto/read/ReceivedCommentsDTO.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.comment.dto.read; + +import BookPick.mvp.domain.comment.entity.Comment; + +import java.util.List; +import java.util.stream.Collectors; + +public record ReceivedCommentsDTO( + List comments +) { + public static ReceivedCommentsDTO from(List comments){ + List commentDetailResList = comments.stream() + .map(CommentDetailRes::of) + .collect(Collectors.toList()); + + return new ReceivedCommentsDTO(commentDetailResList); + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/dto/update/CommentUpdateReq.java b/src/main/java/BookPick/mvp/domain/comment/dto/update/CommentUpdateReq.java new file mode 100644 index 0000000..cfc76c4 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/dto/update/CommentUpdateReq.java @@ -0,0 +1,7 @@ +package BookPick.mvp.domain.comment.dto.update; + +// -- U -- +public record CommentUpdateReq( + String content +) { +} diff --git a/src/main/java/BookPick/mvp/domain/comment/dto/update/CommentUpdateRes.java b/src/main/java/BookPick/mvp/domain/comment/dto/update/CommentUpdateRes.java new file mode 100644 index 0000000..5a1b6ed --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/dto/update/CommentUpdateRes.java @@ -0,0 +1,26 @@ +package BookPick.mvp.domain.comment.dto.update; + + +import BookPick.mvp.domain.comment.entity.Comment; + +import java.time.LocalDateTime; + +public record CommentUpdateRes( + Long commentId, + Long parentId, + String content, + String nickname, + String profileImage, + LocalDateTime updatedAt +) { + public static CommentUpdateRes of(Comment comment) { + return new CommentUpdateRes( + comment.getId(), + comment.getParent() != null ? comment.getParent().getId() : null, + comment.getContent(), + comment.getUser().getNickname(), + comment.getUser().getProfileImageUrl(), + comment.getUpdatedAt() + ); + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/entity/Comment.java b/src/main/java/BookPick/mvp/domain/comment/entity/Comment.java new file mode 100644 index 0000000..a0ed097 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/entity/Comment.java @@ -0,0 +1,58 @@ +package BookPick.mvp.domain.comment.entity; + +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.user.entity.User; +import jakarta.persistence.*; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@Table(name = "comment") +@Builder +@AllArgsConstructor +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long id; + + @ManyToOne + @JoinColumn(name = "parent_comment_id") + private Comment parent; + + // 자식 댓글 관계 + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true) + private List children = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "curation_id") + private Curation curation; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Size(max = 1000) + private String content; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + + public Comment() { + } +} + diff --git a/src/main/java/BookPick/mvp/domain/comment/exception/CommentAccessDeniedException.java b/src/main/java/BookPick/mvp/domain/comment/exception/CommentAccessDeniedException.java new file mode 100644 index 0000000..75b977e --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/exception/CommentAccessDeniedException.java @@ -0,0 +1,11 @@ +// SelfSubscribeDeniedException.java +package BookPick.mvp.domain.comment.exception; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class CommentAccessDeniedException extends BusinessException { + public CommentAccessDeniedException() { + super(ErrorCode.CURATION_ACCESS_DENIED); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/comment/exception/CommentNotFoundException.java b/src/main/java/BookPick/mvp/domain/comment/exception/CommentNotFoundException.java new file mode 100644 index 0000000..538170c --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/exception/CommentNotFoundException.java @@ -0,0 +1,12 @@ +package BookPick.mvp.domain.comment.exception; + + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class CommentNotFoundException extends BusinessException { + public CommentNotFoundException(){ + super(ErrorCode.COMMENT_NOT_FOUND); + } + +} diff --git a/src/main/java/BookPick/mvp/domain/comment/exception/NotFoundParentCommentException.java b/src/main/java/BookPick/mvp/domain/comment/exception/NotFoundParentCommentException.java new file mode 100644 index 0000000..f35bf93 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/exception/NotFoundParentCommentException.java @@ -0,0 +1,12 @@ +package BookPick.mvp.domain.comment.exception; + + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class NotFoundParentCommentException extends BusinessException { + public NotFoundParentCommentException(){ + super(ErrorCode.PARENTS_COMMENT_NOT_FOUND); + } + +} diff --git a/src/main/java/BookPick/mvp/domain/comment/repository/CommentRepository.java b/src/main/java/BookPick/mvp/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..5c372ed --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/repository/CommentRepository.java @@ -0,0 +1,29 @@ +package BookPick.mvp.domain.comment.repository; + +import BookPick.mvp.domain.comment.entity.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + + Page findByCurationId(Long curationId, Pageable pageable); + + + @Query(""" + SELECT cm FROM Comment cm + WHERE cm.curation.user.id = :userId + ORDER BY cm.createdAt DESC + """) + List findLatestCommentsByUserId( + @Param("userId") Long userId, + Pageable pageable + ); + +} diff --git a/src/main/java/BookPick/mvp/domain/comment/service/CommentPolicy.java b/src/main/java/BookPick/mvp/domain/comment/service/CommentPolicy.java new file mode 100644 index 0000000..6e981ac --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/service/CommentPolicy.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.comment.service; + +import BookPick.mvp.domain.comment.dto.create.CommentCreateReq; +import org.springframework.stereotype.Component; + +@Component +public class CommentPolicy { + + boolean isChildrenComment(CommentCreateReq req) { + + // 댓글만드는데 부모댓글이 존재하면 + if( req.parentId() != null){ + return true; + } + + return false; + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/service/CommentService.java b/src/main/java/BookPick/mvp/domain/comment/service/CommentService.java new file mode 100644 index 0000000..51369a5 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/service/CommentService.java @@ -0,0 +1,150 @@ +package BookPick.mvp.domain.comment.service; + +import BookPick.mvp.domain.comment.dto.create.CommentCreateReq; +import BookPick.mvp.domain.comment.dto.create.CommentCreateRes; +import BookPick.mvp.domain.comment.dto.delete.CommentDeleteRes; +import BookPick.mvp.domain.comment.dto.read.CommentDetailRes; +import BookPick.mvp.domain.comment.dto.read.CommentListRes; +import BookPick.mvp.domain.comment.dto.update.CommentUpdateReq; +import BookPick.mvp.domain.comment.dto.update.CommentUpdateRes; +import BookPick.mvp.domain.comment.entity.Comment; +import BookPick.mvp.domain.comment.exception.CommentAccessDeniedException; +import BookPick.mvp.domain.comment.exception.CommentNotFoundException; +import BookPick.mvp.domain.comment.exception.NotFoundParentCommentException; +import BookPick.mvp.domain.comment.repository.CommentRepository; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.global.dto.PageInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CommentService { + private final UserRepository userRepository; + private final CurationRepository curationRepository; + private final CommentRepository commentRepository; + private final CommentPolicy commentPolicy; + + + // -- Create -- + @Transactional + public CommentCreateRes createComment(Long userId, Long curationId, CommentCreateReq req) { + + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + Curation curation = curationRepository.findByIdWithLock(curationId) + .orElseThrow(CurationNotFoundException::new); + + Comment parent = null; + + + + // 자식 댓글이면, + if(commentPolicy.isChildrenComment(req)){ + parent = commentRepository.findById(req.parentId()) + .orElseThrow(NotFoundParentCommentException::new); + } + + Comment comment = Comment.builder() + .user(user) + .parent(parent) + .curation(curation) + .content(req.content()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + Comment saved = commentRepository.save(comment); + curation.increaseCommentCount(); // curation = post + + return CommentCreateRes.from(saved); + } + + + + + // -- Read -- + @Transactional(readOnly = true) + public CommentListRes getCommentList(Long curationId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "createdAt")); + Page commentPage = commentRepository.findByCurationId(curationId, pageable); + + List commentList = commentPage.getContent().stream() + .map(comment -> CommentListRes.CommentSummary.of( + comment.getId(), + comment.getUser().getId(), + comment.getParent() != null ? comment.getParent().getId() : null, + comment.getUser().getNickname(), + comment.getUser().getProfileImageUrl(), + comment.getContent(), + comment.getCreatedAt(), + comment.getUpdatedAt() + )) + .toList(); + + PageInfo pageInfo = PageInfo.of(commentPage); + + return CommentListRes.of(commentList, pageInfo); + } + + @Transactional(readOnly = true) + public CommentDetailRes getCommentDetail(Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + + return CommentDetailRes.of(comment); + } + + + // -- Update -- + @Transactional + public CommentUpdateRes updateComment(Long userId, Long commentId, CommentUpdateReq req) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + + if(!comment.getUser().getId().equals(userId) ){ + throw new CommentAccessDeniedException(); + } + + if (req.content() != null && !req.content().isBlank()) { + comment.setContent(req.content()); + comment.setUpdatedAt(LocalDateTime.now()); + } + + return CommentUpdateRes.of(comment); + } + + + // -- Delete -- + @Transactional + public CommentDeleteRes deleteComment(Long curationId, Long commentId) { + Curation curation = curationRepository.findByIdWithLock(curationId) + .orElseThrow(CurationNotFoundException::new); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + + + + commentRepository.delete(comment); + curation.decreaseCommentCount(); + + return CommentDeleteRes.of(commentId, LocalDateTime.now()); + } + + +} diff --git a/src/main/java/BookPick/mvp/domain/comment/service/PagenationService.java b/src/main/java/BookPick/mvp/domain/comment/service/PagenationService.java new file mode 100644 index 0000000..dfaf905 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/service/PagenationService.java @@ -0,0 +1,17 @@ +package BookPick.mvp.domain.comment.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PagenationService { + + + public int changeMinusPageToZeroPage(int page){ + if(page < 0){ + return 0; + } + return page; + } +} diff --git a/src/main/java/BookPick/mvp/domain/comment/service/ReceivedCommentsService.java b/src/main/java/BookPick/mvp/domain/comment/service/ReceivedCommentsService.java new file mode 100644 index 0000000..cba79e8 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/comment/service/ReceivedCommentsService.java @@ -0,0 +1,23 @@ +package BookPick.mvp.domain.comment.service; + +import BookPick.mvp.domain.comment.dto.read.ReceivedCommentsDTO; +import BookPick.mvp.domain.comment.entity.Comment; +import BookPick.mvp.domain.comment.repository.CommentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import javax.swing.text.html.Option; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReceivedCommentsService { + private final CommentRepository commentRepository; + + public ReceivedCommentsDTO receivedCommentsRead(Long userId){ + List comments = commentRepository.findLatestCommentsByUserId(userId, PageRequest.of(0,3)); + + return ReceivedCommentsDTO.from(comments); + } +} diff --git a/src/main/java/BookPick/mvp/domain/curation/controller/base/CurationController.java b/src/main/java/BookPick/mvp/domain/curation/controller/base/CurationController.java new file mode 100644 index 0000000..bd5cd23 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/controller/base/CurationController.java @@ -0,0 +1,103 @@ +// CurationListController.java에 추가 +package BookPick.mvp.domain.curation.controller.base; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.book.util.kakaoApi.BookSearchService; +import BookPick.mvp.domain.curation.dto.base.CurationReq; +import BookPick.mvp.domain.curation.dto.base.create.CurationCreateRes; +import BookPick.mvp.domain.curation.dto.base.create.CurationCreateResult; +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateReq; +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateRes; +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateResult; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.service.base.create.CurationCreateService; +import BookPick.mvp.domain.curation.service.base.update.CurationUpdateService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.Operation; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/curations") +@RequiredArgsConstructor +public class CurationController { + + private final CurationCreateService curationCreateService; + private final CurationUpdateService curationUpdateService; + private final BookSearchService bookSearchService; + private final CurrentUserCheck currentUserCheck; + + @Operation(summary = "큐레이션 생성(일반 및 임시저장)", description = "새 큐레이션을 생성합니다 drafted가 true면 임시저장", tags = {"Curation"}) + @PostMapping + public ResponseEntity> createCuration( + @Valid @RequestBody CurationReq req, + @AuthenticationPrincipal CustomUserDetails currentUser) { + + currentUserCheck.validateLoginUser(currentUser); + + CurationCreateResult result = curationCreateService.saveCuration(currentUser.getId(), req); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(result.successCode(), result.curationCreateRes())); + } + + + @Operation(summary = "큐레이션 수정 (재발행 및 재 임시저장", description = "큐레이션 정보를 수정", tags = {"Curation"}) + @PatchMapping("/{curationId}") + public ResponseEntity> updateCuration( + @PathVariable Long curationId, + @Valid @RequestBody CurationUpdateReq req, + @AuthenticationPrincipal CustomUserDetails currentUser) { + + currentUserCheck.validateLoginUser(currentUser); + + CurationUpdateResult curationUpdateResult = curationUpdateService.updateCuration(currentUser.getId(), curationId, req); + + return ResponseEntity.ok() + .body(ApiResponse.success(curationUpdateResult.successCode(), curationUpdateResult.curationUpdateRes())); + } + + @Operation( + summary = "큐레이션의 책 구매 링크 제공", + description = "큐레이션 ID로 조회하여 해당 큐레이션의 책 제목으로 카카오 API를 사용해 외부 서점 검색 링크를 제공합니다", + tags = {"Curation"} + ) + @GetMapping("/{curationId}/book-link") + public ResponseEntity> getCurationBookPurchaseLink( + @PathVariable Long curationId, + @AuthenticationPrincipal CustomUserDetails currentUser + ) { + + currentUserCheck.validateLoginUser(currentUser); + + + // 책 제목으로 카카오 API 호출하여 첫 번째 결과의 URL 반환 + String link = bookSearchService.getBookPurchaseLink(curationId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success( + SuccessCode.BOOK_LINK_READ_SUCCESS, + link + )); + } + + + + + +} + + + diff --git a/src/main/java/BookPick/mvp/domain/curation/controller/base/delete/CurationListDeleteController.java b/src/main/java/BookPick/mvp/domain/curation/controller/base/delete/CurationListDeleteController.java new file mode 100644 index 0000000..54e659f --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/controller/base/delete/CurationListDeleteController.java @@ -0,0 +1,55 @@ +package BookPick.mvp.domain.curation.controller.base.delete; + + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.curation.dto.base.delete.CurationListDeleteReq; +import BookPick.mvp.domain.curation.dto.base.delete.CurationListDeleteRes; +import BookPick.mvp.domain.curation.dto.base.delete.CurationDeleteRes; +import BookPick.mvp.domain.curation.enums.common.CurationSuccessCode; +import BookPick.mvp.domain.curation.service.base.delete.CurationDeleteService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.Operation; + +@RestController +@RequestMapping("/api/v1/curations") +@RequiredArgsConstructor +public class CurationListDeleteController { + + private final CurationDeleteService curationDeleteService; + private final CurrentUserCheck currentUserCheck; + + @Operation(summary = "큐레이션 삭제", description = "큐레이션을 삭제합니다", tags = {"Curation"}) + @DeleteMapping("/{curationId}") + public ResponseEntity> deleteCuration( + @PathVariable Long curationId, + @AuthenticationPrincipal CustomUserDetails currentUser) { + + currentUserCheck.validateLoginUser(currentUser); + + CurationDeleteRes res = curationDeleteService.removeCuration(currentUser.getId(), curationId); + return ResponseEntity.ok() + .body(ApiResponse.success(CurationSuccessCode.CURATION_DELETE_SUCCESS, res)); + } + + @Operation(summary = "큐레이션 리스트 삭제", description = "복수의 큐레이션들을 삭제합니다", tags = {"Curation"}) + @DeleteMapping + public ResponseEntity> deleteCurations( + @AuthenticationPrincipal CustomUserDetails currentUser, + @RequestBody CurationListDeleteReq req + ) { + currentUserCheck.validateLoginUser(currentUser); + + CurationListDeleteRes res = curationDeleteService.removeCurations(currentUser.getId(), req); + return ResponseEntity.ok() + .body(ApiResponse.success(CurationSuccessCode.CURATION_LIST_DELETE_SUCCESS, res)); + } +} + + + diff --git a/src/main/java/BookPick/mvp/domain/curation/controller/like/CurationLikeController.java b/src/main/java/BookPick/mvp/domain/curation/controller/like/CurationLikeController.java new file mode 100644 index 0000000..e9596a4 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/controller/like/CurationLikeController.java @@ -0,0 +1,46 @@ +package BookPick.mvp.domain.curation.controller.like; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.curation.enums.common.CurationSuccessCode; +import BookPick.mvp.domain.curation.service.like.CurationLikeService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/curations/like") +@RequiredArgsConstructor +public class CurationLikeController { + + private final CurationLikeService curationLikeService; + private final CurrentUserCheck currentUserCheck; + + + @PostMapping("/{curationId}") + @Operation(summary = "큐레이션 좋아요", description = "큐레이션 좋아요 버튼을 누릅니다.", tags = {"Curation"}) + public ResponseEntity> likeOrUnlikeCuration(@AuthenticationPrincipal CustomUserDetails currentUser + , @PathVariable Long curationId) { + + currentUserCheck.validateLoginUser(currentUser); + + boolean liked = curationLikeService.CurationLikeOrUnlike(currentUser.getId(), curationId); + + if (liked) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(CurationSuccessCode.CURATION_LIKE_SUCCESS, null)); + } else { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(CurationSuccessCode.CURATION_DISLIKE_SUCCESS, null)); + } + } + + + + + +} diff --git a/src/main/java/BookPick/mvp/domain/curation/controller/list/CurationListController.java b/src/main/java/BookPick/mvp/domain/curation/controller/list/CurationListController.java new file mode 100644 index 0000000..93b6630 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/controller/list/CurationListController.java @@ -0,0 +1,86 @@ +package BookPick.mvp.domain.curation.controller.list; + +import BookPick.mvp.domain.ReadingPreference.enums.resCode.PreferenceErrorCode; +import BookPick.mvp.domain.ReadingPreference.enums.resCode.PreferenceSuccessCode; +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.curation.dto.base.get.list.CurationContentRes; +import BookPick.mvp.domain.curation.dto.base.get.list.CurationIdsReq; +import BookPick.mvp.domain.curation.dto.base.get.list.CurationListGetRes; +import BookPick.mvp.domain.curation.enums.common.SortType; +import BookPick.mvp.domain.curation.exception.common.CurationDraftOwnerException; +import BookPick.mvp.domain.curation.service.list.CurationListService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/curations") +@RequiredArgsConstructor +public class CurationListController { + + private final CurationListService curationListService; + private final CurrentUserCheck currentUserCheck; + + + // Todo 1. isDrafted = false 인, 큐레이션 리스트 반환 + + @Operation(summary = "큐레이션 목록 조회", description = "최신순 / 인기순 / 사용자 취향 유사도 순", tags = {"Curation"}) + @GetMapping + public ResponseEntity> getCurations( + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "false") boolean draft, + @AuthenticationPrincipal @Valid CustomUserDetails currentUser + ) { + + currentUserCheck.validateLoginUser(currentUser); + + + // 1. 분류 기준 정하고 + SortType sortType = SortType.fromValue(sort); + + if (draft && !sortType.equals(SortType.SORT_MY)) { + throw new CurationDraftOwnerException(); + } + + // 2. 큐레이션 리스트 얻기 + CurationListGetRes curationListGetRes = curationListService.getCurations(sortType, cursor, size, draft, currentUser.getId()); + + if (curationListGetRes.size() == 0) { + return ResponseEntity.ok() + .body(ApiResponse.success(PreferenceSuccessCode.PREFERENCE_NOT_FOUND, curationListGetRes)); + } + + return ResponseEntity.ok() + .body(ApiResponse.success(SuccessCode.CURATION_LIST_GET_SUCCESS, curationListGetRes)); + } + + @Operation(summary = "큐레이션 ID 목록으로 조회", description = "curationId 배열을 받아 해당 큐레이션 목록 반환", tags = {"Curation"}) + @PostMapping("/by-ids") + public ResponseEntity>> getCurationsByIds( + @RequestBody @Valid CurationIdsReq request, + @AuthenticationPrincipal CustomUserDetails currentUser + ) { + currentUserCheck.validateLoginUser(currentUser); + + List curations = curationListService.getCurationsByIds( + request.curationIds(), + currentUser.getId() + ); + + return ResponseEntity.ok() + .body(ApiResponse.success(SuccessCode.CURATION_LIST_GET_SUCCESS, curations)); + } +} + + + diff --git a/src/main/java/BookPick/mvp/domain/curation/controller/read/CurationReadController.java b/src/main/java/BookPick/mvp/domain/curation/controller/read/CurationReadController.java new file mode 100644 index 0000000..6617a55 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/controller/read/CurationReadController.java @@ -0,0 +1,65 @@ +package BookPick.mvp.domain.curation.controller.read; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.curation.dto.base.get.one.CurationGetRes; +import BookPick.mvp.domain.curation.service.base.read.CurationReadService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/curations") +@RequiredArgsConstructor +public class CurationReadController { + + private final CurationReadService curationReadService; + private final CurrentUserCheck currentUserCheck; + + + @Operation(summary = "큐레이션 일반 조회용 단건 조회", description = "큐레이션 ID로 단건 조회", tags = {"Curation"}) + @GetMapping("/{curationId}") + public ResponseEntity> getCuration( + @PathVariable Long curationId, + HttpServletRequest req, + @AuthenticationPrincipal CustomUserDetails currentUser + ) { + currentUserCheck.validateLoginUser(currentUser); // 미 로그인 사용자 접근 방어 로직 + + + CurationGetRes res = curationReadService.findCuration(curationId, currentUser, req,false); + + + return ResponseEntity.ok() + .body(ApiResponse.success(SuccessCode.CURATION_GET_SUCCESS, res)); + } + + + @Operation(summary = "큐레이션 수정용 단건 조회", description = "큐레이션 ID로 단건 조회", tags = {"Curation"}) + @GetMapping("/{curationId}/edit") + public ResponseEntity> getCurationForEdit( + @PathVariable Long curationId, + HttpServletRequest req, + @AuthenticationPrincipal CustomUserDetails currentUser + ) { + currentUserCheck.validateLoginUser(currentUser); // 미 로그인 사용자 접근 방어 로직 + + + CurationGetRes res = curationReadService.findCuration(curationId, currentUser, req, true); + + + return ResponseEntity.ok() + .body(ApiResponse.success(SuccessCode.CURATION_GET_SUCCESS, res)); + } + + + +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/CurationReq.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/CurationReq.java new file mode 100644 index 0000000..5baa52b --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/CurationReq.java @@ -0,0 +1,24 @@ +package BookPick.mvp.domain.curation.dto.base; + +import BookPick.mvp.domain.curation.dto.base.create.ETC.BookDto; +import BookPick.mvp.domain.curation.dto.base.create.ETC.RecommendDto; +import BookPick.mvp.domain.curation.dto.base.create.ETC.ThumbnailDto; +import BookPick.mvp.domain.curation.enums.common.State; +import jakarta.validation.constraints.NotNull; + +// 메인 요청 DTO +public record CurationReq( + String title, + ThumbnailDto thumbnail, + BookDto book, + String review, + RecommendDto recommend, + + @NotNull + Boolean isDrafted +) { +} + + + + diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/CurationRes.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/CurationRes.java new file mode 100644 index 0000000..941963d --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/CurationRes.java @@ -0,0 +1,52 @@ +// CurationGetRes.java +package BookPick.mvp.domain.curation.dto.base; + +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.enums.common.State; + +import java.time.LocalDateTime; +import java.util.List; + +public record CurationRes( + Long id, + Long userId, + String title, + + ThumbnailInfo thumbnail, + BookInfo book, + String review, + + RecommendInfo recommend, + Boolean isDrafted, + + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static CurationRes from(Curation curation) { + return new CurationRes( + curation.getId(), + curation.getUser().getId(), + curation.getTitle(), + + new ThumbnailInfo(curation.getThumbnailUrl(), curation.getThumbnailColor()), + new BookInfo(curation.getBookTitle(), curation.getBookAuthor(), curation.getBookIsbn()), + curation.getReview(), + + new RecommendInfo(curation.getMoods(), curation.getGenres(), curation.getKeywords(), curation.getStyles()), + curation.getIsDrafted(), + + curation.getCreatedAt(), + curation.getUpdatedAt() + ); + } + + public record ThumbnailInfo(String imageUrl, String imageColor) { + } + + public record BookInfo(String title, String author, String isbn) { + } + + public record RecommendInfo(List moods, List genres, + List keywords, List styles) { + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/create/CurationCreateRes.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/CurationCreateRes.java new file mode 100644 index 0000000..256755c --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/CurationCreateRes.java @@ -0,0 +1,12 @@ +package BookPick.mvp.domain.curation.dto.base.create; + +import BookPick.mvp.domain.curation.entity.Curation; + +public record CurationCreateRes( + Long id, + Boolean isDrafted +) { + public static CurationCreateRes from(Curation curation){ + return new CurationCreateRes(curation.getId(), curation.getIsDrafted()); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/create/CurationCreateResult.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/CurationCreateResult.java new file mode 100644 index 0000000..95e5370 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/CurationCreateResult.java @@ -0,0 +1,13 @@ +package BookPick.mvp.domain.curation.dto.base.create; + +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateRes; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; + +public record CurationCreateResult( + CurationCreateRes curationCreateRes, + SuccessCode successCode +) { + public static CurationCreateResult from(CurationCreateRes curationCreateRes, SuccessCode successCode) { + return new CurationCreateResult(curationCreateRes, successCode); + } +} diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/create/ETC/BookDto.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/ETC/BookDto.java new file mode 100644 index 0000000..dc46fe6 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/ETC/BookDto.java @@ -0,0 +1,16 @@ +package BookPick.mvp.domain.curation.dto.base.create.ETC; + +import BookPick.mvp.domain.curation.entity.Curation; + +// 책 정보 +public record BookDto( + String title, + String author, + String isbn, + String imageUrl +) { + public static BookDto of(Curation curation){ + return new BookDto(curation.getBookTitle(), curation.getBookAuthor(), curation.getBookIsbn(), curation.getBookImageUrl()); + } + +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/create/ETC/RecommendDto.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/ETC/RecommendDto.java new file mode 100644 index 0000000..7ee7158 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/ETC/RecommendDto.java @@ -0,0 +1,9 @@ +package BookPick.mvp.domain.curation.dto.base.create.ETC; + +import java.util.List; +public record RecommendDto( + List moods, + List genres, + List keywords, + List styles +) {} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/create/ETC/ThumbnailDto.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/ETC/ThumbnailDto.java new file mode 100644 index 0000000..070b5f1 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/create/ETC/ThumbnailDto.java @@ -0,0 +1,7 @@ +package BookPick.mvp.domain.curation.dto.base.create.ETC; + +// 썸네일 정보 +public record ThumbnailDto( + String imageUrl, + String imageColor +) {} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/delete/CurationDeleteRes.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/delete/CurationDeleteRes.java new file mode 100644 index 0000000..5de67ee --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/delete/CurationDeleteRes.java @@ -0,0 +1,13 @@ +// CurationDeleteRes.java +package BookPick.mvp.domain.curation.dto.base.delete; + +import java.time.LocalDateTime; + +public record CurationDeleteRes( + Long curationIds, + LocalDateTime deletedAt +) { + public static CurationDeleteRes from(Long id, LocalDateTime deletedAt) { + return new CurationDeleteRes(id, deletedAt); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/delete/CurationListDeleteReq.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/delete/CurationListDeleteReq.java new file mode 100644 index 0000000..e4d78c4 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/delete/CurationListDeleteReq.java @@ -0,0 +1,10 @@ +// CurationDeleteRes.java +package BookPick.mvp.domain.curation.dto.base.delete; + +import java.time.LocalDateTime; +import java.util.List; + +public record CurationListDeleteReq( + List curationIds +) { +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/delete/CurationListDeleteRes.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/delete/CurationListDeleteRes.java new file mode 100644 index 0000000..92079c2 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/delete/CurationListDeleteRes.java @@ -0,0 +1,14 @@ +// CurationDeleteRes.java +package BookPick.mvp.domain.curation.dto.base.delete; + +import java.time.LocalDateTime; +import java.util.List; + +public record CurationListDeleteRes( + List ids, + LocalDateTime deletedAt +) { + public static CurationListDeleteRes from(List ids, LocalDateTime deletedAt) { + return new CurationListDeleteRes(ids, deletedAt); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/BookResInCuration.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/BookResInCuration.java new file mode 100644 index 0000000..39548bd --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/BookResInCuration.java @@ -0,0 +1,12 @@ +package BookPick.mvp.domain.curation.dto.base.get.list; + +public record BookResInCuration( + String title, + String author, + String isbn +) { + + public static BookResInCuration from(String title, String author, String isbn) { + return new BookResInCuration(title, author, isbn); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CurationContentRes.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CurationContentRes.java new file mode 100644 index 0000000..0a23b63 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CurationContentRes.java @@ -0,0 +1,127 @@ +package BookPick.mvp.domain.curation.dto.base.get.list; + +import BookPick.mvp.domain.author.entity.Author; +import BookPick.mvp.domain.curation.dto.prefer.ReadingPreferenceInfo; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.enums.common.State; +import BookPick.mvp.domain.curation.util.gemini.dto.CurationMatchResult; +import BookPick.mvp.domain.user.entity.User; + +import java.time.LocalDateTime; +import java.util.Set; + +public record CurationContentRes( + + // 1. 헤더 + Long curationId, + String title, + + // 2. 작성자 정보 + Long userId, + String nickName, + String profileImageUrl, + String introduction, + + + // 3. 작성 내용 + ThumbnailRes thumbnail, + String review, + BookResInCuration book, + + // 4. 부수 정보 + int likeCount, + int commentCount, + int viewCount, + int similarity, + String matched, + int popularityScore, + boolean isLiked, + Boolean isDrafted, + + // 5. 시간 + LocalDateTime createdAt, + LocalDateTime updatedAt + +) { + public static CurationContentRes from(Curation curation, boolean isLiked) { + return new CurationContentRes( + curation.getId(), + curation.getTitle(), + + curation.getUser().getId(), + curation.getUser().getNickname(), + curation.getUser().getProfileImageUrl(), + curation.getUser().getBio(), + + new ThumbnailRes(curation.getThumbnailUrl(), curation.getThumbnailColor()), + curation.getReview(), +// BookResInCuration.from(curation.getTitle(), curation.getBookAuthor(), curation.getBookIsbn()), + null, + curation.getLikeCount(), + curation.getCommentCount(), + curation.getViewCount(), + 0, + null, + curation.getPopularityScore(), + isLiked, + curation.getIsDrafted(), + + curation.getCreatedAt(), + curation.getUpdatedAt() + ); + } + + public static CurationContentRes from(CurationMatchResult matchResult, ReadingPreferenceInfo preferenceInfo, boolean isLiked) { + Curation curation = matchResult.getCuration(); + return new CurationContentRes( + curation.getId(), + curation.getTitle(), + + curation.getUser().getId(), + matchResult.getUser().getNickname(), + matchResult.getUser().getProfileImageUrl(), + matchResult.getUser().getBio(), + + // Todo 1. 팩토리 메서드 구현 필요 + new ThumbnailRes(curation.getThumbnailUrl(), curation.getThumbnailColor()), + curation.getReview(), new BookResInCuration(curation.getBookTitle(), curation.getBookAuthor(), curation.getBookIsbn()), + curation.getLikeCount(), + + curation.getCommentCount(), + curation.getViewCount(), + getSimilarity(matchResult, preferenceInfo), + matchResult.getMatched(), + curation.getPopularityScore(), + isLiked, + curation.getIsDrafted(), + + curation.getCreatedAt(), + curation.getUpdatedAt() + ); + } + + static Integer getSimilarity(CurationMatchResult matchResult, ReadingPreferenceInfo preferenceInfo) { + Integer similarity = 50; +// Random random = new Random(); + User user = matchResult.getUser(); + + // 1. 매칭된 큐레이션의 작가중 + String author = matchResult.getCuration().getBookAuthor(); + + + //2. 유저 독서취향의 작가들 안에 존재하면 +20 + Set favoriteAuthors = preferenceInfo.favoriteAuthors(); + if(favoriteAuthors.contains(author)){ + similarity+=10; + } + + // 3. 각 키워드들마다 매칭되는거 있으면 +10 + similarity += matchResult.getTotalMatchCount()*10; + + + // 4. 1의 자리수 랜덤값으로 조정하여 다채롭게 (mvp단계 한정) + // similarity+=random.nextInt(10); + + return similarity; + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CurationIdsReq.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CurationIdsReq.java new file mode 100644 index 0000000..112fa65 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CurationIdsReq.java @@ -0,0 +1,11 @@ +package BookPick.mvp.domain.curation.dto.base.get.list; + +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +public record CurationIdsReq( + @NotEmpty(message = "curationIds는 필수입니다.") + List curationIds +) { +} diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CurationListGetRes.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CurationListGetRes.java new file mode 100644 index 0000000..21e2b7d --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CurationListGetRes.java @@ -0,0 +1,39 @@ +// CurationListGetRes.java +package BookPick.mvp.domain.curation.dto.base.get.list; + +import BookPick.mvp.domain.curation.enums.common.SortType; + +import java.util.ArrayList; +import java.util.List; + +public record CurationListGetRes( + String sortType, + String description, + List content, + int size, + boolean hasNext, + Long nextCursor +) { + public static CurationListGetRes from(SortType sortType, List content, + boolean hasNext, Long nextCursor) { + return new CurationListGetRes( + sortType.getValue(), + sortType.getDescription(), + content, + content.size(), + hasNext, + nextCursor + ); + } + + public static CurationListGetRes ofEmpty(SortType sortType) { + return new CurationListGetRes( + sortType.getValue(), + sortType.getDescription(), + new ArrayList<>(), + 0, + false, + null + ); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CursorPage.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CursorPage.java new file mode 100644 index 0000000..803d41f --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/CursorPage.java @@ -0,0 +1,14 @@ +package BookPick.mvp.domain.curation.dto.base.get.list; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class CursorPage { + private final List content; + private final boolean hasNext; + private final Long nextCursor; +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/ThumbnailRes.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/ThumbnailRes.java new file mode 100644 index 0000000..f644679 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/list/ThumbnailRes.java @@ -0,0 +1,7 @@ +package BookPick.mvp.domain.curation.dto.base.get.list; + + +public record ThumbnailRes( + String imageUrl, + String imageColor +) {} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/CurationGetRes.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/CurationGetRes.java new file mode 100644 index 0000000..e31d736 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/CurationGetRes.java @@ -0,0 +1,80 @@ +// CurationGetRes.java +package BookPick.mvp.domain.curation.dto.base.get.one; + +import BookPick.mvp.domain.curation.dto.base.get.one.field.BookInfo; +import BookPick.mvp.domain.curation.dto.base.get.one.field.RecommendInfo; +import BookPick.mvp.domain.curation.dto.base.get.one.field.ThumbnailInfo; +import BookPick.mvp.domain.curation.entity.Curation; + +import java.time.LocalDateTime; + +public record CurationGetRes( + Long id, + Long userId, + String nickName, + String profileImageUrl, + String introduction, + boolean subscribed, + String title, + ThumbnailInfo thumbnail, + BookInfo book, + String review, + RecommendInfo recommend, + Boolean isLiked, + Boolean isDrafted, + Integer likeCount, + Integer viewCount, + Integer CommentCount, + + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static CurationGetRes from(Curation curation, boolean subscribed, boolean isLiked) { + return new CurationGetRes( + curation.getId(), + curation.getUser().getId(), + curation.getUser().getNickname(), + curation.getUser().getProfileImageUrl(), + curation.getUser().getBio(), + subscribed, + curation.getTitle(), + new ThumbnailInfo(curation.getThumbnailUrl(), curation.getThumbnailColor()), + null, + curation.getReview(), + new RecommendInfo(curation.getMoods(), curation.getGenres(), + curation.getKeywords(), curation.getStyles()), + isLiked, + curation.getIsDrafted(), + curation.getLikeCount(), + curation.getViewCount(), + curation.getCommentCount(), + curation.getCreatedAt(), + curation.getUpdatedAt() + ); + } + public static CurationGetRes fromOwnerView(Curation curation, boolean subscribed, boolean isLiked) { + return new CurationGetRes( + curation.getId(), + curation.getUser().getId(), + curation.getUser().getNickname(), + curation.getUser().getProfileImageUrl(), + curation.getUser().getBio(), + subscribed, + curation.getTitle(), + new ThumbnailInfo(curation.getThumbnailUrl(), curation.getThumbnailColor()), + BookInfo.of(curation), + curation.getReview(), + new RecommendInfo(curation.getMoods(), curation.getGenres(), + curation.getKeywords(), curation.getStyles()), + isLiked, + curation.getIsDrafted(), + curation.getLikeCount(), + curation.getViewCount(), + curation.getCommentCount(), + curation.getCreatedAt(), + curation.getUpdatedAt() + ); + } + + +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/field/BookInfo.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/field/BookInfo.java new file mode 100644 index 0000000..efc0531 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/field/BookInfo.java @@ -0,0 +1,11 @@ +package BookPick.mvp.domain.curation.dto.base.get.one.field; + +import BookPick.mvp.domain.curation.entity.Curation; + +public record BookInfo(String title, String author, String isbn, String imageUrl) + +{ + public static BookInfo of(Curation curation){ + return new BookInfo(curation.getBookTitle(), curation.getBookAuthor(), curation.getBookIsbn(), curation.getBookImageUrl()); + } +} diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/field/RecommendInfo.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/field/RecommendInfo.java new file mode 100644 index 0000000..c8bd6b5 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/field/RecommendInfo.java @@ -0,0 +1,7 @@ +package BookPick.mvp.domain.curation.dto.base.get.one.field; + +import java.util.List; + +public record RecommendInfo(List moods, List genres, + List keywords, List styles) { +} diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/field/ThumbnailInfo.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/field/ThumbnailInfo.java new file mode 100644 index 0000000..0c502f6 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/get/one/field/ThumbnailInfo.java @@ -0,0 +1,4 @@ +package BookPick.mvp.domain.curation.dto.base.get.one.field; + +public record ThumbnailInfo(String imageUrl, String imageColor) { +} diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/update/CurationUpdateReq.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/update/CurationUpdateReq.java new file mode 100644 index 0000000..c879c4d --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/update/CurationUpdateReq.java @@ -0,0 +1,15 @@ +// CurationUpdateReq.java +package BookPick.mvp.domain.curation.dto.base.update; + +import BookPick.mvp.domain.curation.dto.base.create.ETC.BookDto; +import BookPick.mvp.domain.curation.dto.base.create.ETC.RecommendDto; +import BookPick.mvp.domain.curation.dto.base.create.ETC.ThumbnailDto; + +public record CurationUpdateReq( + String title, + ThumbnailDto thumbnail, + BookDto book, + String review, + RecommendDto recommend, + Boolean isDrafted +) {} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/update/CurationUpdateRes.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/update/CurationUpdateRes.java new file mode 100644 index 0000000..ecd9697 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/update/CurationUpdateRes.java @@ -0,0 +1,13 @@ +// CurationUpdateRes.java +package BookPick.mvp.domain.curation.dto.base.update; + +import BookPick.mvp.domain.curation.entity.Curation; + +public record CurationUpdateRes( + Long id, + boolean isDrafted +) { + public static CurationUpdateRes from(Curation curation) { + return new CurationUpdateRes(curation.getId(), curation.getIsDrafted()); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/base/update/CurationUpdateResult.java b/src/main/java/BookPick/mvp/domain/curation/dto/base/update/CurationUpdateResult.java new file mode 100644 index 0000000..b8b11e3 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/base/update/CurationUpdateResult.java @@ -0,0 +1,12 @@ +package BookPick.mvp.domain.curation.dto.base.update; + +import BookPick.mvp.global.api.SuccessCode.SuccessCode; + +public record CurationUpdateResult( + CurationUpdateRes curationUpdateRes, + SuccessCode successCode +) { + public static CurationUpdateResult from(CurationUpdateRes curationUpdateRes, SuccessCode successCode) { + return new CurationUpdateResult(curationUpdateRes, successCode); + } +} diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/like/LikeDto.java b/src/main/java/BookPick/mvp/domain/curation/dto/like/LikeDto.java new file mode 100644 index 0000000..1084d25 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/like/LikeDto.java @@ -0,0 +1,5 @@ +package BookPick.mvp.domain.curation.dto.like; + +public class LikeDto { + +} diff --git a/src/main/java/BookPick/mvp/domain/curation/dto/prefer/ReadingPreferenceInfo.java b/src/main/java/BookPick/mvp/domain/curation/dto/prefer/ReadingPreferenceInfo.java new file mode 100644 index 0000000..2cadb04 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/dto/prefer/ReadingPreferenceInfo.java @@ -0,0 +1,37 @@ +package BookPick.mvp.domain.curation.dto.prefer; + +import BookPick.mvp.domain.ReadingPreference.entity.ReadingPreference; +import BookPick.mvp.domain.author.entity.Author; +import BookPick.mvp.domain.book.entity.Book; + +import java.util.List; +import java.util.Set; + +public record ReadingPreferenceInfo( + Long userId, + String mbti, + Set favoriteBooks, + Set favoriteAuthors, + List readingHabits, + List moods, + List genres, + List keywords, + List readingStyles + ) { + + // 엔티티 → DTO 변환 + public static ReadingPreferenceInfo from(ReadingPreference preference) { + return new ReadingPreferenceInfo( + preference.getUser().getId(), + preference.getMbti(), + preference.getFavoriteBooks(), + preference.getFavoriteAuthors(), + preference.getMoods(), + preference.getReadingHabits(), + preference.getGenres(), + preference.getKeywords(), + preference.getReadingStyles() + ); + } + +} diff --git a/src/main/java/BookPick/mvp/domain/curation/entity/Curation.java b/src/main/java/BookPick/mvp/domain/curation/entity/Curation.java new file mode 100644 index 0000000..11ca742 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/entity/Curation.java @@ -0,0 +1,179 @@ +package BookPick.mvp.domain.curation.entity; + +import BookPick.mvp.domain.comment.entity.Comment; +import BookPick.mvp.domain.curation.dto.base.CurationReq; +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateReq; +import BookPick.mvp.domain.curation.enums.common.State; +import BookPick.mvp.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Builder +@Entity +@Getter +@Setter +@EntityListeners(AuditingEntityListener.class) +@Table(name = "curation") +@AllArgsConstructor +public class Curation { + + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + private String title; + + + // Todo 2. 추후 썸네일 클래스로 변경 필요 + private String thumbnailUrl; + private String thumbnailColor; + + + // Todo 1. 추후 Book 클래스로 변경 필요 + @Column(nullable = false) + private String bookTitle; + private String bookAuthor; + private String bookIsbn; + private String bookImageUrl; + @Column(columnDefinition = "TEXT") + private String review; + + // 매핑 테이블로 변경! + @ElementCollection + @CollectionTable(name = "curation_moods", joinColumns = @JoinColumn(name = "curation_id")) + @Column(name = "mood") + private List moods; + @ElementCollection + @CollectionTable(name = "curation_genres", joinColumns = @JoinColumn(name = "curation_id")) + @Column(name = "genre") + private List genres; + @ElementCollection + @CollectionTable(name = "curation_keywords", joinColumns = @JoinColumn(name = "curation_id")) + @Column(name = "keyword") + private List keywords; + @ElementCollection + @CollectionTable(name = "curation_styles", joinColumns = @JoinColumn(name = "curation_id")) + @Column(name = "style") + private List styles; + + @Builder.Default + @Column(name = "like_count") + private Integer likeCount = 0; + @Builder.Default + @Column(name = "view_count") + private Integer viewCount = 0; + @Builder.Default + @Column(name = "comment_count") + private Integer commentCount = 0; + @Builder.Default + @Column(name = "popularity_score") + private Integer popularityScore = 0; + + @Column(name = "is_draft") + private Boolean isDrafted; + + @OneToMany(mappedBy = "curation", cascade = CascadeType.ALL, orphanRemoval = true) + private List comments = new ArrayList<>(); + + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + @LastModifiedDate + private LocalDateTime updatedAt; + private LocalDateTime deletedAt; + private LocalDateTime publishedAt; + + //Todo 1. 소프트 델리트 구현 필요 + + public Curation() { + + } + + + // 수정 + public void curationUpdate(CurationUpdateReq req) { + this.title = req.title(); + this.thumbnailUrl = req.thumbnail().imageUrl(); + this.thumbnailColor = req.thumbnail().imageColor(); + this.bookTitle = req.book().title(); + this.bookAuthor = req.book().author(); + this.bookIsbn = req.book().isbn(); + this.bookImageUrl = req.book().imageUrl(); + this.review = req.review(); + this.moods = req.recommend().moods(); + this.genres = req.recommend().genres(); + this.keywords = req.recommend().keywords(); + this.styles = req.recommend().styles(); + } + + + // 조회수 + public void increaseViewCount() { + this.viewCount++; + updatePopularityScore(); // 인기도 재계산 + } + + // 인기도 + public void updatePopularityScore() { + this.popularityScore = (likeCount * 3) + (commentCount * 2) + (viewCount); + } + + + // 댓글 개수 + public void increaseCommentCount() { + this.commentCount++; + this.updatePopularityScore(); + } + + public void decreaseCommentCount() { + if (this.commentCount > 0) { + this.commentCount--; + } + this.updatePopularityScore(); + } + + // --------------------------- + + // 팩토리 메서드 + public static Curation from(CurationReq req, User user) { + return Curation.builder() + .user(user) + .title(req.title()) + .thumbnailUrl(req.thumbnail().imageUrl()) + .thumbnailColor(req.thumbnail().imageColor()) + .bookTitle(req.book().title()) + .bookAuthor(req.book().author()) + .bookIsbn(req.book().isbn()) + .bookImageUrl(req.book().imageUrl()) + .review(req.review()) + .moods(req.recommend().moods()) + .genres(req.recommend().genres()) + .keywords(req.recommend().keywords()) + .styles(req.recommend().styles()) + .isDrafted(req.isDrafted()) + .build(); + } + + // 발행 + public void publish() { + this.isDrafted = false; + this.publishedAt = LocalDateTime.now(); + } + + // 임시저장 + public void draft() { + this.isDrafted = true; + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/entity/CurationLike.java b/src/main/java/BookPick/mvp/domain/curation/entity/CurationLike.java new file mode 100644 index 0000000..3afec5f --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/entity/CurationLike.java @@ -0,0 +1,47 @@ +package BookPick.mvp.domain.curation.entity; + +import BookPick.mvp.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Builder +@Table(name = "curation_like") +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) + +public class CurationLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "curation_id") + private Curation curation; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "created_at") + @CreatedDate + private LocalDateTime createdAt; + + public CurationLike() { + + } + + + // 좋아요는 수정 시각이 필요가 없음 + +} diff --git a/src/main/java/BookPick/mvp/domain/curation/enums/common/CurationErrorCode.java b/src/main/java/BookPick/mvp/domain/curation/enums/common/CurationErrorCode.java new file mode 100644 index 0000000..bc53c0b --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/enums/common/CurationErrorCode.java @@ -0,0 +1,19 @@ +package BookPick.mvp.domain.curation.enums.common; + +import BookPick.mvp.global.enums.ErrorCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum CurationErrorCode implements ErrorCodeInterface { + + CURATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 큐레이션을 찾을 수 없습니다."), + CURATION_ACCESS_DENIED(HttpStatus.FORBIDDEN, "큐레이션 접근 권한이 없습니다."); + + + + private final HttpStatus status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/enums/common/CurationSuccessCode.java b/src/main/java/BookPick/mvp/domain/curation/enums/common/CurationSuccessCode.java new file mode 100644 index 0000000..d9288eb --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/enums/common/CurationSuccessCode.java @@ -0,0 +1,35 @@ +package BookPick.mvp.domain.curation.enums.common; + +import BookPick.mvp.global.enums.SuccessCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum CurationSuccessCode implements SuccessCodeInterface { + + + + // 임시저장 + CREATE_DRAFTED_CURATION_SUCCESS(HttpStatus.CREATED, "큐레이션 임시저장에 성공했습니다"), + + // 좋아요 + CURATION_LIKE_SUCCESS(HttpStatus.OK, "큐레이션 좋아요를 성공적으로 실행하였습니다."), + CURATION_DISLIKE_SUCCESS(HttpStatus.OK, "큐레이션 좋아요 취소를 성공적으로 실행하였습니다."), + + + + + // 삭제 + CURATION_DELETE_SUCCESS(HttpStatus.OK, "큐레이션을 성공적으로 삭제하였습니다."), + CURATION_LIST_DELETE_SUCCESS(HttpStatus.OK, "다수의 큐레이션을 성공적으로 삭제하였습니다."); + + + // 조회 + + + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/BookPick/mvp/domain/curation/enums/common/SortType.java b/src/main/java/BookPick/mvp/domain/curation/enums/common/SortType.java new file mode 100644 index 0000000..808ffd8 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/enums/common/SortType.java @@ -0,0 +1,28 @@ +// SortType.java +package BookPick.mvp.domain.curation.enums.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum SortType { + SORT_LATEST("latest", "최신순 정렬"), + SORT_POPULAR("popular", "인기순 정렬"), + SORT_SIMILARITY("similarity", "취향 유사도순 정렬"), + SORT_LIKED("liked", "사용자 좋아요 큐레이션 리스트"), + SORT_MY("my", "사용자 작성 큐레이션 리스트"); + + private final String value; + private final String description; + + + public static SortType fromValue(String value) { + for (SortType type : values()) { + if (type.value.equals(value)) { + return type; + } + } + return SORT_LATEST; // 기본값 + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/enums/common/State.java b/src/main/java/BookPick/mvp/domain/curation/enums/common/State.java new file mode 100644 index 0000000..1c512e9 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/enums/common/State.java @@ -0,0 +1,6 @@ +package BookPick.mvp.domain.curation.enums.common; + +public enum State { + PUBLISHED, + DRAFTED +} diff --git a/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationAccessDeniedException.java b/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationAccessDeniedException.java new file mode 100644 index 0000000..4c582d6 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationAccessDeniedException.java @@ -0,0 +1,11 @@ +// SelfSubscribeDeniedException.java +package BookPick.mvp.domain.curation.exception.common; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class CurationAccessDeniedException extends BusinessException { + public CurationAccessDeniedException() { + super(ErrorCode.CURATION_ACCESS_DENIED); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationAlreadyPublishedException.java b/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationAlreadyPublishedException.java new file mode 100644 index 0000000..f971a18 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationAlreadyPublishedException.java @@ -0,0 +1,11 @@ +// SelfSubscribeDeniedException.java +package BookPick.mvp.domain.curation.exception.common; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class CurationAlreadyPublishedException extends BusinessException { + public CurationAlreadyPublishedException() { + super(ErrorCode.CURATION_ACCESS_DENIED); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationDraftOwnerException.java b/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationDraftOwnerException.java new file mode 100644 index 0000000..7aa3d82 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationDraftOwnerException.java @@ -0,0 +1,11 @@ +// SelfSubscribeDeniedException.java +package BookPick.mvp.domain.curation.exception.common; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class CurationDraftOwnerException extends BusinessException { + public CurationDraftOwnerException() { + super(ErrorCode.CURATION_DRAFT_ACCESS_DENIED); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationNotFoundException.java b/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationNotFoundException.java new file mode 100644 index 0000000..bb82a5d --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/exception/common/CurationNotFoundException.java @@ -0,0 +1,11 @@ +// CurationNotFoundException.java +package BookPick.mvp.domain.curation.exception.common; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class CurationNotFoundException extends BusinessException { + public CurationNotFoundException() { + super(ErrorCode.CURATION_NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/repository/CurationRepository.java b/src/main/java/BookPick/mvp/domain/curation/repository/CurationRepository.java new file mode 100644 index 0000000..be05417 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/repository/CurationRepository.java @@ -0,0 +1,103 @@ +package BookPick.mvp.domain.curation.repository; + +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.entity.CurationLike; +import BookPick.mvp.domain.user.entity.User; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface CurationRepository extends JpaRepository { + + List findByUserIdAndIsDraftedOrderByCreatedAtDesc(Long userId, boolean isDrafted, Pageable pageable); + + + // 사이즈만큼 최신순으로 불러오는 함수 + List findAllByOrderByCreatedAtDesc(Pageable pageable); + + // Drafted 여부에 따라 가져옴 + List findAllByIsDraftedOrderByCreatedAtDesc(Boolean isDrafted, Pageable pageable); + + + @Query("SELECT c FROM Curation c WHERE c.id <= :cursor and c.isDrafted = :isDrafted order BY c.createdAt DESC, c.id DESC") + List findLatestCurations(@Param("cursor") Long cursor, + @Param("isDrafted") boolean isDrafted, + Pageable pageable); + + // 인기순 + @Query(""" + SELECT c FROM Curation c + WHERE c.isDrafted = false AND + (:cursorScore IS NULL + OR c.popularityScore < :cursorScore + OR (c.popularityScore = :cursorScore AND c.id < :cursorId)) + ORDER BY c.popularityScore DESC, c.id DESC + """) + List findCurationsByPopularity( + @Param("cursorScore") Integer cursorScore, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + + // Gemini 추천 결과로 큐레이션 찾기 (Batch Fetch로 N+1 방지) + // 1. m : 컬럼 별칭 + // 2. + @Query(""" + SELECT DISTINCT c FROM Curation c + LEFT JOIN c.moods m + LEFT JOIN c.genres g + LEFT JOIN c.keywords k + LEFT JOIN c.styles s + WHERE c.deletedAt IS NULL and c.isDrafted is false and c.user.id != :userId + AND (m IN :moods OR g IN :genres OR k IN :keywords OR s IN :styles) + ORDER BY c.popularityScore DESC + """) + List findPublishedCurationsByRecommendation( + @Param("userId") Long userId, + @Param("moods") List moods, + @Param("genres") List genres, + @Param("keywords") List keywords, + @Param("styles") List styles + ); + + + + + Optional findByUserIdAndId(Long userId, Long id); + + + // 7. + @Query("SELECT c FROM Curation c JOIN FETCH c.user WHERE c.id = :id") + Optional findByIdWithUser(@Param("id") Long id); + + List findByIdIn(Collection ids); + + + // Like (실제 발행된 것만) + @Query(""" + select c from Curation c + join CurationLike cl on cl.curation = c + where c.isDrafted is false and cl.user.id = :userId + order by cl.createdAt desc + """) + List findLikedCurationsByUser(@Param("userId") Long userId, Pageable pageable); + + // Pessimistic lock for preventing deadlocks when updating comment count + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM Curation c WHERE c.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + + // Pessimistic lock with user join for view count updates + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM Curation c JOIN FETCH c.user WHERE c.id = :id") + Optional findByIdWithUserAndLock(@Param("id") Long id); + +} + diff --git a/src/main/java/BookPick/mvp/domain/curation/repository/like/CurationLikeRepository.java b/src/main/java/BookPick/mvp/domain/curation/repository/like/CurationLikeRepository.java new file mode 100644 index 0000000..1b435c5 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/repository/like/CurationLikeRepository.java @@ -0,0 +1,29 @@ +package BookPick.mvp.domain.curation.repository.like; + +import BookPick.mvp.domain.curation.entity.CurationLike; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + + +public interface CurationLikeRepository extends JpaRepository { + Optional findByUserIdAndCurationId(Long userID, Long curationId); + + List findAllByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + Optional findByUserId(Long userId); + + + // CurationLikeRepository + @Query(""" + select cl.curation.id + from CurationLike cl + where cl.user.id = :userId + and cl.curation.id in :curationIds + """) + List findLikedCurationIds(Long userId, List curationIds); + +} diff --git a/src/main/java/BookPick/mvp/domain/curation/service/base/create/CurationCreateService.java b/src/main/java/BookPick/mvp/domain/curation/service/base/create/CurationCreateService.java new file mode 100644 index 0000000..69d07e7 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/service/base/create/CurationCreateService.java @@ -0,0 +1,75 @@ +// CurationListService.java +package BookPick.mvp.domain.curation.service.base.create; + +import BookPick.mvp.domain.curation.dto.base.CurationReq; +import BookPick.mvp.domain.curation.dto.base.create.CurationCreateRes; +import BookPick.mvp.domain.curation.dto.base.create.CurationCreateResult; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.domain.user.service.subscribe.CurationSubscribeService; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CurationCreateService { + + private final CurationRepository curationRepository; + private final UserRepository userRepository; + + + // 분기 + @Transactional + public CurationCreateResult saveCuration(Long userId, CurationReq req) { + + + // 발행 + if(!req.isDrafted()){ + return CurationCreateResult.from(publishNewCuration(userId, req), SuccessCode.CURATION_PUBLISH_SUCCESS); + } + else{ + return CurationCreateResult.from(draftNewCuration(userId, req), SuccessCode.CURATION_DRAFT_SUCCESS); + } + + + } + // 큐레이션 발행 + public CurationCreateRes publishNewCuration(Long userId, CurationReq req) { + + + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + Curation curation = Curation.from(req, user ); + curation.publish(); + Curation saved = curationRepository.save(curation); + + return CurationCreateRes.from(saved); + + } + + + // 큐레이션 임시저장 + public CurationCreateRes draftNewCuration(Long userId, CurationReq req) { + + + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + Curation curation = Curation.from(req, user ); + curation.draft(); + Curation saved = curationRepository.save(curation); + + return CurationCreateRes.from(saved); + + } + + + +} diff --git a/src/main/java/BookPick/mvp/domain/curation/service/base/delete/CurationDeleteService.java b/src/main/java/BookPick/mvp/domain/curation/service/base/delete/CurationDeleteService.java new file mode 100644 index 0000000..dfe8542 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/service/base/delete/CurationDeleteService.java @@ -0,0 +1,78 @@ +// CurationListService.java +package BookPick.mvp.domain.curation.service.base.delete; + +import BookPick.mvp.domain.curation.dto.base.delete.CurationDeleteRes; +import BookPick.mvp.domain.curation.dto.base.delete.CurationListDeleteReq; +import BookPick.mvp.domain.curation.dto.base.delete.CurationListDeleteRes; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.exception.common.CurationAccessDeniedException; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.domain.user.service.subscribe.CurationSubscribeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CurationDeleteService { + + private final CurationRepository curationRepository; + private final CurationLikeRepository curationLikeRepository; + private final UserRepository userRepository; + private final CurationSubscribeService curationSubscribeService; + + + // -- 큐레이션 하드 삭제 -- + @Transactional + public CurationDeleteRes removeCuration(Long userId, Long curationId) { + Curation curation = curationRepository.findById(curationId) + .orElseThrow(CurationNotFoundException::new); + + if (!curation.getUser().getId().equals(userId)) { + throw new CurationAccessDeniedException(); + } + + curationRepository.delete(curation); + + return CurationDeleteRes.from(curation.getId(), LocalDateTime.now()); + } + + + // -- 큐레이션 복수 삭제 -- + @Transactional + public CurationListDeleteRes removeCurations(Long userId, CurationListDeleteReq req) { + + + + // 1. 큐레이션 Id들 가지고 큐레이션 리스트 찾기 + List curations = curationRepository.findByIdIn((req.curationIds())); + + // 2. 큐레이션들이 존재하지 않으면 삭제할 큐레이션을 찾을 수 없습니다. + if (curations.size() != req.curationIds().size()) { + throw new CurationNotFoundException(); + } + + + for (Curation curation : curations) { + if (!curation.getUser().getId().equals(userId)) { + throw new CurationAccessDeniedException(); + } + curationRepository.delete(curation); + } + + List deletedIds = curations.stream() + .map(Curation::getId) + .toList(); + + + + + return CurationListDeleteRes.from(deletedIds,LocalDateTime.now()); +} +} diff --git a/src/main/java/BookPick/mvp/domain/curation/service/base/read/CurationReadService.java b/src/main/java/BookPick/mvp/domain/curation/service/base/read/CurationReadService.java new file mode 100644 index 0000000..d6d7dbc --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/service/base/read/CurationReadService.java @@ -0,0 +1,76 @@ +// CurationListService.java +package BookPick.mvp.domain.curation.service.base.read; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.curation.dto.base.get.one.CurationGetRes; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.entity.CurationLike; +import BookPick.mvp.domain.curation.exception.common.CurationAccessDeniedException; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.domain.user.service.subscribe.CurationSubscribeService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class CurationReadService { + + private final CurationRepository curationRepository; + private final CurationLikeRepository curationLikeRepository; + private final UserRepository userRepository; + private final CurationSubscribeService curationSubscribeService; + + + + + + // -- 큐레이션 단건 조회 -- + @Transactional + public CurationGetRes findCuration(Long curationId, CustomUserDetails user, HttpServletRequest req, boolean isEdit) { + boolean isLikedCuration = false; + boolean isSubscribedCurator = false; + CurationGetRes res; + + Curation curation = curationRepository.findByIdWithUserAndLock(curationId) + .orElseThrow(CurationNotFoundException::new); + + curation.increaseViewCount(); // 큐레이션 조회수 +1 + + + if (user != null) { + // 1. 좋아요 정보 찾기 + Optional curationLike = curationLikeRepository.findByUserIdAndCurationId(user.getId(), curationId); + if (curationLike.isPresent()) { + isLikedCuration = true; + } + + // 2. 큐레이터 구독 여부 조회 + isSubscribedCurator = curationSubscribeService.isSubscribeCurator(user.getId(), curation.getUser().getId()); + + // 3. 큐레이션 작성자면 책 정보 넣어서 큐레이션 반환 + if (isEdit) { + if(curation.getUser().getId().equals(user.getId())){ + return CurationGetRes.fromOwnerView(curation, isSubscribedCurator, isLikedCuration); + } + else{ + throw new CurationAccessDeniedException(); + } + } + } + + return CurationGetRes.from(curation, isSubscribedCurator, isLikedCuration); + } + + + + + + +} diff --git a/src/main/java/BookPick/mvp/domain/curation/service/base/update/CurationUpdateService.java b/src/main/java/BookPick/mvp/domain/curation/service/base/update/CurationUpdateService.java new file mode 100644 index 0000000..d0db1d0 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/service/base/update/CurationUpdateService.java @@ -0,0 +1,101 @@ +// CurationListService.java +package BookPick.mvp.domain.curation.service.base.update; + +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateReq; +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateRes; +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateResult; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.exception.common.CurationAccessDeniedException; +import BookPick.mvp.domain.curation.exception.common.CurationAlreadyPublishedException; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.domain.user.service.subscribe.CurationSubscribeService; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CurationUpdateService { + + private final CurationRepository curationRepository; + private final CurationLikeRepository curationLikeRepository; + private final UserRepository userRepository; + private final CurationSubscribeService curationSubscribeService; + + @Transactional + public CurationUpdateResult updateCuration(Long userId, Long curationId, CurationUpdateReq req) { + Curation curation = curationRepository.findById(curationId) + .orElseThrow(CurationNotFoundException::new); + + if (curation.getIsDrafted()) { + if (req.isDrafted()) { + // 임시저장 -> 임시저장 + return CurationUpdateResult.from(reDraftCuration(userId, curation, req), SuccessCode.CURATION_DRAFT_UPDATE_SUCCESS); + } else { + // 임시저장 -> 발행본 + return CurationUpdateResult.from(publishDraftedCuration(userId, curation, req), SuccessCode.DRAFTED_CURATION_PUBLISH_SUCCESS); + } + } + + + // 발행본 -> 발행본 + else { + if (!req.isDrafted()) { + return CurationUpdateResult.from(modifyPublishedCuration(userId, curation, req), SuccessCode.CURATION_UPDATE_SUCCESS); + } else { + throw new CurationAlreadyPublishedException(); + } + } + + } + + + + /*---------------------------------------------------------------------------------------------------*/ + + // 임시저장 -> 임시저장 + public CurationUpdateRes reDraftCuration(Long userId, Curation curation, CurationUpdateReq req) { + + if (!curation.getUser().getId().equals(userId)) { + throw new CurationAccessDeniedException(); + } + + + curation.curationUpdate(req); // 임시저장 및 발행 처리도 가능 + curation.draft(); + + return CurationUpdateRes.from(curation); + } + + // 임시저장 -> 발행본 + public CurationUpdateRes publishDraftedCuration(Long userId, Curation curation, CurationUpdateReq req) { + + + if (!curation.getUser().getId().equals(userId)) { + throw new CurationAccessDeniedException(); + } + + curation.curationUpdate(req); // 임시저장 및 발행 처리도 가능 + curation.publish(); + + return CurationUpdateRes.from(curation); + } + + // 발행 -> 발행 + public CurationUpdateRes modifyPublishedCuration(Long userId, Curation curation, CurationUpdateReq req) { + + + if (!curation.getUser().getId().equals(userId)) { + throw new CurationAccessDeniedException(); + } + + curation.curationUpdate(req); // 임시저장 및 발행 처리도 가능 + + return CurationUpdateRes.from(curation); + } + +} diff --git a/src/main/java/BookPick/mvp/domain/curation/service/like/CurationLikeService.java b/src/main/java/BookPick/mvp/domain/curation/service/like/CurationLikeService.java new file mode 100644 index 0000000..ab9f57e --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/service/like/CurationLikeService.java @@ -0,0 +1,70 @@ +package BookPick.mvp.domain.curation.service.like; + +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.entity.CurationLike; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class CurationLikeService { + private final UserRepository userRepository; + private final CurationRepository curationRepository; + private final CurationLikeRepository curationLikeRepository; + + + // 1. 포스트 좋아요 + @Transactional + public boolean CurationLikeOrUnlike(Long userId, Long curationId) { + + // 1. 포스트 아이디 얻기 + Curation curation = curationRepository.findByIdWithLock(curationId) + .orElseThrow(CurationNotFoundException::new); + // 2. 유저 아이디 얻기 + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + + // 3. 해당 유저가 이미 좋아요를 눌렀는지 체크 + // 3.1 유저 좋아요 정보 가져오기 + Optional opt = curationLikeRepository.findByUserIdAndCurationId(userId, curationId); + + // 3.2 좋아요 정보가 없으면 생성 후 해당 게시글 좋아요 카운트 +1 + if (opt.isEmpty()) { + CurationLike curationLike = CurationLike.builder() + .user(user) + .curation(curation) + .build(); + + curationLikeRepository.save(curationLike); + + curation.setLikeCount(curation.getLikeCount() + 1); + + + return true; + } + + // 3.2 좋아요 정보가 있으면 삭제 후 해당 게시글 좋아요 카운트 -1 + else { + curationLikeRepository.delete(opt.get()); + + if (curation.getLikeCount() > 0) { + curation.setLikeCount(curation.getLikeCount() - 1); + } + + return false; + } + } + +} + + diff --git a/src/main/java/BookPick/mvp/domain/curation/service/list/CurationListService.java b/src/main/java/BookPick/mvp/domain/curation/service/list/CurationListService.java new file mode 100644 index 0000000..f9f1bb0 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/service/list/CurationListService.java @@ -0,0 +1,144 @@ +package BookPick.mvp.domain.curation.service.list; + +import BookPick.mvp.domain.ReadingPreference.entity.ReadingPreference; +import BookPick.mvp.domain.curation.dto.base.get.list.CurationContentRes; +import BookPick.mvp.domain.curation.dto.base.get.list.CurationListGetRes; +import BookPick.mvp.domain.curation.dto.base.get.list.CursorPage; +import BookPick.mvp.domain.curation.dto.prefer.ReadingPreferenceInfo; +import BookPick.mvp.domain.curation.enums.common.SortType; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.curation.util.gemini.dto.CurationMatchResult; +import BookPick.mvp.domain.curation.util.list.Handler.CurationPageHandler; +import BookPick.mvp.domain.curation.util.list.similarity.CurationMatchResultPagination; +import BookPick.mvp.domain.ReadingPreference.repository.ReadingPreferenceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CurationListService { + + private final CurationPageHandler pageHandler; + private final ReadingPreferenceRepository readingPreferenceRepository; + private final CurationRecommendationService curationRecommendationService; + private final CurationLikeRepository curationLikeRepository; + private final CurationRepository curationRepository; + + + // 1. 큐레이션 리스트 조회 + public CurationListGetRes getCurations(SortType sortType, Long cursor, int size, boolean drafted, Long userId) { + + // 1. 내 취향 유사도 순 + if (sortType == SortType.SORT_SIMILARITY) { + + // 1. 유저 독서 취향 반환 + ReadingPreference readingPreference = readingPreferenceRepository.findByUserId(userId).orElse(null); + if(!readingPreference.isCompleted()){return CurationListGetRes.ofEmpty(sortType);} + ReadingPreferenceInfo preferenceInfo = ReadingPreferenceInfo.from(readingPreference); + + // 2. 매칭된 큐레이션 리스트 조회 (캐싱됨 - 첫 요청 이후 Gemini 호출 안함) + List recommended = curationRecommendationService.recommend(preferenceInfo); + + //3. 매칭된 큐레이션 페이지네이션 (cursor를 offset으로 사용) + List paginated = CurationMatchResultPagination.paginate(recommended, cursor, PageRequest.of(0, size + 1)); + + //4. 유저가 스크롤시, 다음 조회할 값있는지 + boolean hasNext = paginated.size() > size; + + // 5. 콘텐츠가 더있으면 자르고 다음페이지에서 보여줌 + // 더 없으면 그냥 보여줌 + List contentResults = hasNext ? paginated.subList(0, size) : paginated; + + // 6. 다음 커서 반환 (offset 기반) + int currentOffset = (cursor != null) ? cursor.intValue() : 0; + Long nextCursor = hasNext ? (long)(currentOffset + size) : null; + + // 7-1. 좋아요 여부 계산을 위한 큐레이션 ID 목록 추출 + List curationIds = contentResults.stream() + .map(r -> r.getCuration().getId()) + .toList(); + + // 7-2. 사용자가 좋아요 누른 큐레이션 ID 조회 + Set likedIds = curationLikeRepository + .findLikedCurationIds(userId, curationIds) + .stream() + .collect(Collectors.toSet()); + + + // 8. 큐레이션 리스트 Dto에 들어갈 단건 dto 생성 + List content = contentResults.stream() + .map(result -> CurationContentRes.from( + result, + preferenceInfo, + likedIds.contains(result.getCuration().getId()) + )) + .collect(Collectors.toList()); + + //9. 큐레이션 리스트로 감싸기 + return CurationListGetRes.from(sortType, content, hasNext, nextCursor); + } + + // 2) 내 취향 유사도순이 아닌 경우 + List curations = pageHandler.getCurationsPage(userId, sortType, cursor, size, null, drafted); + CursorPage page = pageHandler.createCursorPage(curations, size); + + + // 2-1. 좋아요 여부 계산을 위한 큐레이션 ID 목록 추출 + List curationIds = page.getContent().stream() + .map(Curation::getId) + .toList(); + + // 2-2. 사용자가 좋아요 누른 큐레이션 ID 조회 + Set likedIds = curationLikeRepository + .findLikedCurationIds(userId, curationIds) + .stream() + .collect(Collectors.toSet()); + + + List content = page.getContent().stream() + .map(c -> CurationContentRes.from( + c, + likedIds.contains(c.getId()) + )) + .collect(Collectors.toList()); + + + + return CurationListGetRes.from( + sortType, + content, + page.isHasNext(), + page.getNextCursor() + ); + } + + + + + // Issue 1) DTO 만들어서 독서취향 정보 레이어간 소통 vs 사용자 독서취향 실시간 수정 반영 고려 + // 1. 사용자는 독서취향을 한번 설정하면 자주 바꾸지 않는다. + // 2. 따라서 DTO 생성 후 넣기로 결정 + + // 2. curationId 목록으로 큐레이션 조회 + @Transactional(readOnly = true) + public List getCurationsByIds(List curationIds, Long userId) { + List curations = curationRepository.findByIdIn(curationIds); + + Set likedIds = curationLikeRepository + .findLikedCurationIds(userId, curationIds) + .stream() + .collect(Collectors.toSet()); + + return curations.stream() + .map(c -> CurationContentRes.from(c, likedIds.contains(c.getId()))) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/service/list/CurationRecommendationService.java b/src/main/java/BookPick/mvp/domain/curation/service/list/CurationRecommendationService.java new file mode 100644 index 0000000..9b0a5c2 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/service/list/CurationRecommendationService.java @@ -0,0 +1,40 @@ +package BookPick.mvp.domain.curation.service.list; + +import BookPick.mvp.domain.curation.dto.prefer.ReadingPreferenceInfo; +import BookPick.mvp.domain.curation.util.gemini.dto.CurationMatchResult; +import BookPick.mvp.domain.curation.util.gemini.prompt.ContentPromptTemplate; +import BookPick.mvp.domain.curation.util.gemini.service.GeminiService; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CurationRecommendationService { + + private final GeminiService geminiService; + + @Cacheable(value = "gemini-recommendations", key = "#preferenceInfo.userId()") + public List recommend(ReadingPreferenceInfo preferenceInfo) { + + // + return geminiService.recommendCurationsWithMatch( + + // 유저 ID + preferenceInfo.userId(), + + + // 1. 제미나이 프롬프트 생성 + ContentPromptTemplate.builder() + .mbti(preferenceInfo.mbti()) + .mood(String.join(", ", preferenceInfo.moods())) //, 으로 하나의 문자열로 변경 -> 제미나이 프롬프트에 넣기 위해서 + .readingMethod(String.join(", ", preferenceInfo.readingHabits())) + .genre(String.join(", ", preferenceInfo.genres())) + .keyword(String.join(", ", preferenceInfo.keywords())) + .readingStyle(String.join(", ", preferenceInfo.readingStyles())) + .build() + ); + } +} diff --git a/src/main/java/BookPick/mvp/domain/curation/util/converter/StringListConverter.java b/src/main/java/BookPick/mvp/domain/curation/util/converter/StringListConverter.java new file mode 100644 index 0000000..4161800 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/converter/StringListConverter.java @@ -0,0 +1,33 @@ +// StringListConverter.java +package BookPick.mvp.domain.curation.util.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.List; + +@Converter +public class StringListConverter implements AttributeConverter, String> { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + try { + return mapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + try { + return mapper.readValue(dbData, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/util/gemini/GeminiErrorException.java b/src/main/java/BookPick/mvp/domain/curation/util/gemini/GeminiErrorException.java new file mode 100644 index 0000000..fc85bc7 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/gemini/GeminiErrorException.java @@ -0,0 +1,12 @@ +package BookPick.mvp.domain.curation.util.gemini; + +import BookPick.mvp.domain.curation.util.gemini.enums.GeminiErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class GeminiErrorException extends BusinessException { + + public GeminiErrorException(){ + super(GeminiErrorCode.GEMINI_API_CALL_FAILED); + + } +} diff --git a/src/main/java/BookPick/mvp/domain/curation/util/gemini/client/GeminiClient.java b/src/main/java/BookPick/mvp/domain/curation/util/gemini/client/GeminiClient.java new file mode 100644 index 0000000..828c3f9 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/gemini/client/GeminiClient.java @@ -0,0 +1,68 @@ +package BookPick.mvp.domain.curation.util.gemini.client; + + + +import BookPick.mvp.domain.curation.util.gemini.GeminiErrorException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.http.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +@RequiredArgsConstructor +public class GeminiClient { + + private final GeminiConfig config; + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + public String callGemini(String systemInstruction, String userContent) { + try { + String url = config.getApiUrl() + "?key=" + config.getApiKey(); + + // 요청 바디 생성 (올바른 구조) + String requestBody = String.format(""" + { + "system_instruction": { + "parts": [ + {"text": "%s"} + ] + }, + "contents": [ + { + "parts": [ + {"text": "%s"} + ] + } + ] + } + """, + systemInstruction.replace("\"", "\\\"").replace("\n", "\\n"), + userContent.replace("\"", "\\\"").replace("\n", "\\n") + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + entity, + String.class + ); + + // 응답에서 텍스트 추출 + JsonNode root = objectMapper.readTree(response.getBody()); + return root.path("candidates").get(0) + .path("content").path("parts").get(0) + .path("text").asText(); + + } catch (Exception e) { + throw new GeminiErrorException(); + } + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/util/gemini/client/GeminiConfig.java b/src/main/java/BookPick/mvp/domain/curation/util/gemini/client/GeminiConfig.java new file mode 100644 index 0000000..d9f3922 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/gemini/client/GeminiConfig.java @@ -0,0 +1,17 @@ +package BookPick.mvp.domain.curation.util.gemini.client; + + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import lombok.Getter; + +@Configuration +@Getter +public class GeminiConfig { + + @Value("${api.gemini.api.key}") + private String apiKey; + + @Value("${api.gemini.api.url}") + private String apiUrl; +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/util/gemini/dto/CurationMatchResult.java b/src/main/java/BookPick/mvp/domain/curation/util/gemini/dto/CurationMatchResult.java new file mode 100644 index 0000000..a6d2303 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/gemini/dto/CurationMatchResult.java @@ -0,0 +1,99 @@ +package BookPick.mvp.domain.curation.util.gemini.dto; + +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.user.entity.User; +import lombok.Builder; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Builder +public class CurationMatchResult { + private Curation curation; + private User user; + private String matchedMood; + private String matchedGenre; + private String matchedKeyword; + private String matchedStyle; + private int totalMatchCount; + private String matched; + + public static CurationMatchResult of(Curation curation, + User user, + String recommendedMood, + String recommendedGenre, + String recommendedKeyword, + String recommendedStyle) { + + String matchedMood = null; + String matchedGenre = null; + String matchedKeyword = null; + String matchedStyle = null; + int matchCount = 0; + List matchedItems = new ArrayList<>(); + + // Mood 매칭 + // curation.getMoods() -> 1+N 문제 발생 , 아래도 동일 + if (curation.getMoods() != null && curation.getMoods().contains(recommendedMood)) { + matchedMood = recommendedMood; + matchedItems.add(recommendedMood); + matchCount++; + } + + // Genre 매칭 + if (curation.getGenres() != null && curation.getGenres().contains(recommendedGenre)) { + matchedGenre = recommendedGenre; + matchedItems.add(recommendedGenre); + matchCount++; + } + + // Keyword 매칭 + if (curation.getKeywords() != null && curation.getKeywords().contains(recommendedKeyword)) { + matchedKeyword = recommendedKeyword; + matchedItems.add(recommendedKeyword); + matchCount++; + } + + // Style 매칭 + if (curation.getStyles() != null && curation.getStyles().contains(recommendedStyle)) { + matchedStyle = recommendedStyle; + matchedItems.add(recommendedStyle); + matchCount++; + } + + String matchedString = String.join(", ", matchedItems); + + return CurationMatchResult.builder() + .curation(curation) + .user(user) + .matchedMood(matchedMood) + .matchedGenre(matchedGenre) + .matchedKeyword(matchedKeyword) + .matchedStyle(matchedStyle) + .totalMatchCount(matchCount) + .matched(matchedString) + .build(); + } + + // 일치한 것만 문자열로 반환 + public String getMatchedString() { + StringBuilder sb = new StringBuilder(); + + if (matchedMood != null) { + sb.append("Mood: ").append(matchedMood).append("\n"); + } + if (matchedGenre != null) { + sb.append("Genre: ").append(matchedGenre).append("\n"); + } + if (matchedKeyword != null) { + sb.append("Keyword: ").append(matchedKeyword).append("\n"); + } + if (matchedStyle != null) { + sb.append("Style: ").append(matchedStyle).append("\n"); + } + + return sb.toString(); + } +} diff --git a/src/main/java/BookPick/mvp/domain/curation/util/gemini/enums/GeminiErrorCode.java b/src/main/java/BookPick/mvp/domain/curation/util/gemini/enums/GeminiErrorCode.java new file mode 100644 index 0000000..dd67358 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/gemini/enums/GeminiErrorCode.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.curation.util.gemini.enums; + +import BookPick.mvp.global.enums.ErrorCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum GeminiErrorCode implements ErrorCodeInterface { + + GEMINI_API_CALL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Gemini API 호출에 실패했습니다."); + + + + private final HttpStatus status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/util/gemini/prompt/ContentPromptTemplate.java b/src/main/java/BookPick/mvp/domain/curation/util/gemini/prompt/ContentPromptTemplate.java new file mode 100644 index 0000000..9d75cac --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/gemini/prompt/ContentPromptTemplate.java @@ -0,0 +1,35 @@ +package BookPick.mvp.domain.curation.util.gemini.prompt; +import lombok.Builder; +import lombok.Getter; + + +@Getter +@Builder +public class ContentPromptTemplate { // + + private String mbti; + private String mood; + private String readingMethod; + private String genre; + private String keyword; + private String readingStyle; + + public String toContentPrompt() { + return String.format(""" + **User Input** + MBTI: %s + Mood: %s + Reading Method: %s + Genre: %s + Keyword: %s + Reading Style: %s + """, + mbti, + mood, + readingMethod, + genre, + keyword, + readingStyle + ); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/util/gemini/prompt/SystemInstructionPromptTemplate.java b/src/main/java/BookPick/mvp/domain/curation/util/gemini/prompt/SystemInstructionPromptTemplate.java new file mode 100644 index 0000000..6becb06 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/gemini/prompt/SystemInstructionPromptTemplate.java @@ -0,0 +1,26 @@ +package BookPick.mvp.domain.curation.util.gemini.prompt; + +import org.springframework.stereotype.Component; + +@Component +public class SystemInstructionPromptTemplate { + + + // 1. 유저의 독서 취향을 가지고 + // 2. 하나씩 뽑아라 아래 항목들중에서 + // 3. 그리고 해당 키워드들을 가지고 큐레이션을 찾아서 유저한테 소개할 것이다. + private static final String SYSTEM_PROMPT = """ + Based on the user's reading preferences, select exactly one item from each of Mood, Genre, Keyword, and ReadingStyle. + You must only choose from the provided lists. + Output only the selected values, one per line, without any labels or formatting. + + Mood list: 퇴근 후, 따뜻한 차 한잔, 비 오는 날, 눈 오는 날, 지하철·버스, 카페, 침대에서, 공원, 도서관, 서점에서, 새벽 시간, 주말 오후, 점심시간, 늦은 밤, 잠들기 전, 혼자만의 시간, 창가에서, 음악과 함께, 여행 중, 휴가 중 + Genre list: 소설, 에세이, 역사, 예술, 자기개발, 경제, 심리학, 사회, 교육, 과학, 철학, 종교 + Keyword list: 위로, 성장, 사랑, 공감, 지식, 유머, 추리, 모험, 판타지, 현실, 미래, 과거 + Reading Style list: 속독형, 몰입형, 정독형, 취향 탐색형, 스토리 중심, 지식 위주, 감성적, 논리적, 창의적, 실용적, 비평적, 상상력 중시, 느긋한 독서, 깊이 있는 사색, 가볍게 즐기기 + """; + + public String getSystemInstructionPrompt() { + return SYSTEM_PROMPT; + } +} diff --git a/src/main/java/BookPick/mvp/domain/curation/util/gemini/service/GeminiService.java b/src/main/java/BookPick/mvp/domain/curation/util/gemini/service/GeminiService.java new file mode 100644 index 0000000..7979da5 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/gemini/service/GeminiService.java @@ -0,0 +1,84 @@ +package BookPick.mvp.domain.curation.util.gemini.service; + + +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.util.gemini.client.GeminiClient; +import BookPick.mvp.domain.curation.util.gemini.dto.CurationMatchResult; +import BookPick.mvp.domain.curation.util.gemini.prompt.SystemInstructionPromptTemplate; +import BookPick.mvp.domain.curation.util.gemini.prompt.ContentPromptTemplate; +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 +public class GeminiService { + + private final GeminiClient geminiClient; + private final SystemInstructionPromptTemplate systemPromptTemplate; + private final CurationRepository curationRepository; + + public String generateRecommendation(ContentPromptTemplate contentTemplate) { + String systemPrompt = systemPromptTemplate.getSystemInstructionPrompt(); + String userPrompt = contentTemplate.toContentPrompt(); + + return geminiClient.callGemini(systemPrompt, userPrompt); + } + + public String[] parseResult(String result) { + return result.trim().split("\n"); + } + + @Transactional(readOnly = true) + public List recommendCurationsWithMatch(Long userId, ContentPromptTemplate contentTemplate) { + + + // 1. Gemini에게 추천 받기 + String result = generateRecommendation(contentTemplate); + String[] parsed = parseResult(result); + + // 2. 파싱된 결과 (각 1개씩) + String recommendedMood = parsed[0].trim(); + String recommendedGenre = parsed[1].trim(); + String recommendedKeyword = parsed[2].trim(); + String recommendedStyle = parsed[3].trim(); + + // 3. DB에서 큐레이션 찾기 + List curations = curationRepository.findPublishedCurationsByRecommendation( + userId, + List.of(recommendedMood), + List.of(recommendedGenre), + List.of(recommendedKeyword), + List.of(recommendedStyle) + ); + + // 4. 일치 정보와 함께 반환 (일치 개수 많은 순, 0점 제외) + + + // 5. 메모리 측정 +// Runtime runtime = Runtime.getRuntime(); +// long before = runtime.totalMemory() - runtime.freeMemory(); + + List results = curations.stream() + .map(curation -> CurationMatchResult.of( + curation, + curation.getUser(), + recommendedMood, + recommendedGenre, + recommendedKeyword, + recommendedStyle + )) + .filter(matchResult -> matchResult.getTotalMatchCount() > 0) + .sorted((a, b) -> Integer.compare(b.getTotalMatchCount(), a.getTotalMatchCount())) + .collect(Collectors.toList()); + +// long after = runtime.totalMemory() - runtime.freeMemory(); +// System.out.println("사용 메모리: " + (after - before) / 1024 + " KB"); // 100 배치당 1079KB 사용 + + return results; + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/util/list/Handler/CurationPageHandler.java b/src/main/java/BookPick/mvp/domain/curation/util/list/Handler/CurationPageHandler.java new file mode 100644 index 0000000..fa7db05 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/list/Handler/CurationPageHandler.java @@ -0,0 +1,61 @@ +package BookPick.mvp.domain.curation.util.list.Handler; + + +import BookPick.mvp.domain.curation.dto.prefer.ReadingPreferenceInfo; +import BookPick.mvp.domain.curation.enums.common.SortType; +import BookPick.mvp.domain.curation.dto.base.get.list.CurationContentRes; +import BookPick.mvp.domain.curation.dto.base.get.list.CursorPage; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.util.list.fetcher.CurationFetcher; +import BookPick.mvp.domain.ReadingPreference.Exception.fail.UserReadingPreferenceNotExisted; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class CurationPageHandler { + + private final CurationFetcher curationFetcher; + + // 1. 데이터 조회 (size+1개 : +1을 하는 이유는 다음 것을 항상 확인하기 위해서 + public List getCurationsPage(Long userId, SortType sortType, Long cursor, int size, ReadingPreferenceInfo readingPreferenceInfo, boolean drafted) { + Pageable pageable = PageRequest.of(0, size + 1); + + // 취향유사도 순일 경우 preferenceInfo를 사용, 나머지는 user 관련 정보 필요 없음 + if (sortType == SortType.SORT_SIMILARITY && readingPreferenceInfo == null) { + throw new UserReadingPreferenceNotExisted(); + } + + + // 1) DB에서 실제로 가져오는 로직 (fetch : DB에서 가져오는 행위) + return curationFetcher.fetchCurations(userId, sortType, cursor, pageable, readingPreferenceInfo, drafted); + } + + // 2. 커서 페이징 처리 + public CursorPage createCursorPage(List curations, int size) { + boolean hasNext = curations.size() > size; + Long nextCursor = curationFetcher.calculateNextCursor(curations, size, hasNext); + List content = hasNext ? curations.subList(0, size) : curations; + + return new CursorPage<>(content, hasNext, nextCursor); + } + + // 3. DTO 변환 (isLiked 포함) + public List convertToContentRes( + List curations, + Set likedIds + ) { + return curations.stream() + .map(c -> CurationContentRes.from( + c, + likedIds.contains(c.getId()) + )) + .toList(); + } + +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/util/list/fetcher/CurationFetcher.java b/src/main/java/BookPick/mvp/domain/curation/util/list/fetcher/CurationFetcher.java new file mode 100644 index 0000000..5aa4b58 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/list/fetcher/CurationFetcher.java @@ -0,0 +1,81 @@ +package BookPick.mvp.domain.curation.util.list.fetcher; + +import BookPick.mvp.domain.curation.dto.prefer.ReadingPreferenceInfo; +import BookPick.mvp.domain.curation.entity.CurationLike; +import BookPick.mvp.domain.curation.enums.common.SortType; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.curation.service.list.CurationRecommendationService; +import BookPick.mvp.domain.curation.util.gemini.dto.CurationMatchResult; +import BookPick.mvp.domain.curation.util.list.similarity.CurationMatchResultPagination; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.stream.Collectors; + + +@Component +@RequiredArgsConstructor +public class CurationFetcher { + + private final CurationRepository curationRepository; + private final CurationLikeRepository curationLikeRepository; + private final CurationRecommendationService curationRecommendationService; + + + // 1. sort Type별로 큐레이션 리스트 가져오기 + public List fetchCurations(Long userId, SortType sortType, Long cursor, Pageable pageable, ReadingPreferenceInfo readingPreferenceInfo, boolean drafted) { + + + // 1) 맨 처음 페이지 로딩 + if (cursor == null) { + if (sortType.equals(SortType.SORT_LATEST)) + return curationRepository.findAllByIsDraftedOrderByCreatedAtDesc(drafted, pageable); // 취향 유사도 만들기 전까진 최신순 + } + + // 2) 🌟분류 기준 🌟 + return switch (sortType) { + // 인기순 + case SORT_POPULAR -> { + Integer cursorScore = null; + if (cursor != null) { + Curation cursorCuration = curationRepository.findById(cursor) + .orElseThrow(() -> new IllegalArgumentException("Invalid cursor")); + cursorScore = cursorCuration.getPopularityScore(); + } + yield curationRepository.findCurationsByPopularity(cursorScore, cursor, pageable); + } + + // 최신순 + case SORT_LATEST -> curationRepository.findLatestCurations(cursor, drafted, pageable); + + // 취향 유사도순 (얘는 항상 publish 된것만 = isDraftd : false) + case SORT_SIMILARITY -> { + List recommended = curationRecommendationService.recommend(readingPreferenceInfo); + List paginated = CurationMatchResultPagination.paginate(recommended, cursor, pageable); + yield paginated.stream().map(CurationMatchResult::getCuration).collect(Collectors.toList()); + } + + // 좋아요 순 +// case SORT_LIKED -> { +// List likedCurationList = curationLikeRepository.findAllByUserIdOrderByCreatedAtDesc(userId, pageable); +// yield likedCurationList.stream() +// .map(CurationLike::getCuration) +// .toList(); +// } + + // 좋아요 순 + case SORT_LIKED -> curationRepository.findLikedCurationsByUser(userId, pageable); + + // 내가 작성한 순 + case SORT_MY -> curationRepository.findByUserIdAndIsDraftedOrderByCreatedAtDesc(userId, drafted, pageable); + }; + } + + public Long calculateNextCursor(List curations, int size, boolean hasNext) { + return hasNext ? curations.get(size).getId() : null; + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/curation/util/list/fetcher/CurationSimilarityFetcher.java b/src/main/java/BookPick/mvp/domain/curation/util/list/fetcher/CurationSimilarityFetcher.java new file mode 100644 index 0000000..458f74c --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/list/fetcher/CurationSimilarityFetcher.java @@ -0,0 +1,4 @@ +package BookPick.mvp.domain.curation.util.list.fetcher; + +public class CurationSimilarityFetcher { +} diff --git a/src/main/java/BookPick/mvp/domain/curation/util/list/similarity/CurationMatchResultPagination.java b/src/main/java/BookPick/mvp/domain/curation/util/list/similarity/CurationMatchResultPagination.java new file mode 100644 index 0000000..3ceb88b --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/list/similarity/CurationMatchResultPagination.java @@ -0,0 +1,26 @@ +package BookPick.mvp.domain.curation.util.list.similarity; + +import BookPick.mvp.domain.curation.util.gemini.dto.CurationMatchResult; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public class CurationMatchResultPagination { + + /** + * offset 기준으로 큐레이션 리스트 페이징 처리 + * cursor를 offset으로 해석 (null이면 0부터 시작) + */ + public static List paginate(List curationMatchResults, Long cursor, Pageable pageable) { + // cursor를 offset으로 해석 (null이면 0) + int offset = (cursor != null) ? cursor.intValue() : 0; + + // offset이 범위를 벗어나면 빈 리스트 반환 + if (offset >= curationMatchResults.size()) { + return List.of(); + } + + int end = Math.min(offset + pageable.getPageSize(), curationMatchResults.size()); + return curationMatchResults.subList(offset, end); + } +} diff --git a/src/main/java/BookPick/mvp/domain/curation/util/list/similarity/SimilarityCalculator.java b/src/main/java/BookPick/mvp/domain/curation/util/list/similarity/SimilarityCalculator.java new file mode 100644 index 0000000..6c3a436 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/list/similarity/SimilarityCalculator.java @@ -0,0 +1,4 @@ +package BookPick.mvp.domain.curation.util.list.similarity; + +public class SimilarityCalculator { +} diff --git a/src/main/java/BookPick/mvp/domain/curation/util/list/similarity/SimilarityMatcher.java b/src/main/java/BookPick/mvp/domain/curation/util/list/similarity/SimilarityMatcher.java new file mode 100644 index 0000000..eb8ed60 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/curation/util/list/similarity/SimilarityMatcher.java @@ -0,0 +1,4 @@ +package BookPick.mvp.domain.curation.util.list.similarity; + +public class SimilarityMatcher { +} diff --git a/src/main/java/BookPick/mvp/domain/user/controller/base/UserController.java b/src/main/java/BookPick/mvp/domain/user/controller/base/UserController.java new file mode 100644 index 0000000..161a9c7 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/controller/base/UserController.java @@ -0,0 +1,90 @@ +//package BookPick.mvp.domain.user.controller.base; +// +// +//import BookPick.mvp.domain.auth.service.CustomUserDetails; +//import BookPick.mvp.domain.user.dto.base.UserReq; +//import BookPick.mvp.domain.user.dto.base.UserRes; +//import BookPick.mvp.domain.user.dto.base.delete.UserSoftDeleteRes; +//import BookPick.mvp.domain.user.enums.user.UserSuccessCode; +//import BookPick.mvp.domain.user.exception.common.UserNameNotNullException; +//import BookPick.mvp.domain.user.service.base.UserService; +//import BookPick.mvp.domain.user.util.AdminManager; +//import BookPick.mvp.global.api.ApiResponse; +//import io.swagger.v3.oas.annotations.Operation; +//import jakarta.validation.Valid; +//import lombok.RequiredArgsConstructor; +//import org.springframework.http.HttpStatus; +//import org.springframework.http.ResponseEntity; +//import org.springframework.security.core.annotation.AuthenticationPrincipal; +//import org.springframework.web.bind.annotation.*; +// +//@RestController +//@RequiredArgsConstructor +//@RequestMapping("/api/v1") +//public class UserController { +// +// private final UserService userService; +// private final AdminManager adminManager; +// +// // 유저 생성 +// @PostMapping +// @Operation(summary = "유저 추가", description = "새로운 유저를 추가합니다", tags = {"User"}) +// public ResponseEntity> createUser(@AuthenticationPrincipal CustomUserDetails currentUser, +// @RequestBody @Valid UserReq req) { +// if (adminManager.isAdmin(currentUser.getAuthorities())) { +// throw new UserNameNotNullException(); +// } +// +// UserRes res = userService.CreateUser(req); +// +// return ResponseEntity.status(HttpStatus.CREATED) +// .body(ApiResponse.success(UserSuccessCode.CREATE_USER_SUCCESS, res)); +// } +// +// // 유저 조회 +// @GetMapping +// @Operation(summary = "유저 프로필 조회", description = "로그인한 사용자의 프로필을 조회합니다.", tags = {"User"}) +// public ResponseEntity> getUseProfile(@AuthenticationPrincipal CustomUserDetails currentUser) { +// UserRes res = userService.userProfileGet(currentUser.getId()); +// +// return ResponseEntity.status(HttpStatus.OK) +// .body(ApiResponse.success(UserSuccessCode.GET_MY_PROFILE_SUCCESS, res)); +// } +// +// +// +// // 유저 수정 +// @PatchMapping +// @Operation(summary = "유저 프로필 수정", description = "로그인한 사용자의 프로필을 수정합니다.", tags = {"User"}) +// public ResponseEntity> updateUserProfile(@AuthenticationPrincipal CustomUserDetails currentUser +// , @RequestBody @Valid UserReq req) { +// UserRes res = userService.userProfileUpdate(currentUser.getId(), req); +// +// return ResponseEntity.status(HttpStatus.OK) +// .body(ApiResponse.success(UserSuccessCode.UPDATE_MY_PROFILE_SUCCESS, res)); +// } +// +// +// // 유저 소프트 삭제 +// @DeleteMapping("/soft") +// @Operation(summary = "유저 프로필 소프트 삭제", description = "로그인한 사용자의 프로필을 임시 삭제합니다.", tags = {"User"}) +// public ResponseEntity> softDeleteUser(@AuthenticationPrincipal CustomUserDetails currentUser) { +// UserSoftDeleteRes res = userService.softDeleteProfile(currentUser.getId()); +// +// return ResponseEntity.status(HttpStatus.OK) +// .body(ApiResponse.success(UserSuccessCode.GET_MY_PROFILE_SUCCESS, res)); +// } +// +// +// // 유저 하드 삭제 +// @DeleteMapping("/hard") +// @Operation(summary = "유저 프로필 하드 삭제", description = "로그인한 사용자의 프로필을 완전히 삭제합니다.", tags = {"User"}) +// public ResponseEntity> hardDeleteUser(@AuthenticationPrincipal CustomUserDetails currentUser) { +// if (adminManager.isAdmin(currentUser.getAuthorities())) { +// userService.hardDeleteUserProfile(currentUser.getId()); +// } +// +// return ResponseEntity.status(HttpStatus.OK) +// .body(ApiResponse.success(UserSuccessCode.GET_MY_PROFILE_SUCCESS, null)); +// } +//} diff --git a/src/main/java/BookPick/mvp/domain/user/controller/passWord/PasswordContorller.java b/src/main/java/BookPick/mvp/domain/user/controller/passWord/PasswordContorller.java new file mode 100644 index 0000000..b405642 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/controller/passWord/PasswordContorller.java @@ -0,0 +1,34 @@ +package BookPick.mvp.domain.user.controller.passWord; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.user.dto.passWord.PassWordChangeReq; +import BookPick.mvp.domain.user.enums.user.UserSuccessCode; +import BookPick.mvp.domain.user.service.passWord.PassWordService; +import BookPick.mvp.global.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@RequestMapping("/api/v1/my/password") +@RequiredArgsConstructor +public class PasswordContorller { + private final PassWordService passWordService; + + @PostMapping("/change") + public ResponseEntity> changePassword(@AuthenticationPrincipal CustomUserDetails currentUser, + @RequestBody @Valid PassWordChangeReq req) { + passWordService.PassWordChange(currentUser.getId(), req); + + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(UserSuccessCode.PASSWORD_CHANGE_SUCCESS, null)); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/controller/profile/ProfileController.java b/src/main/java/BookPick/mvp/domain/user/controller/profile/ProfileController.java new file mode 100644 index 0000000..ccdfdd8 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/controller/profile/ProfileController.java @@ -0,0 +1,73 @@ +package BookPick.mvp.domain.user.controller.profile; + + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.user.dto.base.UserReq; +import BookPick.mvp.domain.user.dto.base.UserRes; +import BookPick.mvp.domain.user.dto.base.delete.UserSoftDeleteRes; +import BookPick.mvp.domain.user.enums.user.UserSuccessCode; +import BookPick.mvp.domain.user.service.base.UserService; +import BookPick.mvp.domain.user.util.AdminManager; +import BookPick.mvp.global.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/profiles") +public class ProfileController { + + private final UserService userService; + private final AdminManager adminManager; + + + // 1. 프로필 조회 + @GetMapping("/me") + @Operation(summary = "유저 프로필 조회", description = "로그인한 사용자의 프로필을 조회합니다.", tags = {"User"}) + public ResponseEntity> getUseProfile(@AuthenticationPrincipal CustomUserDetails currentUser) { + UserRes res = userService.userProfileGet(currentUser.getId()); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(UserSuccessCode.GET_MY_PROFILE_SUCCESS, res)); + } + + + // 2. 프로필 수정 + @PatchMapping("/me") + @Operation(summary = "유저 프로필 수정", description = "로그인한 사용자의 프로필을 수정합니다.", tags = {"User"}) + public ResponseEntity> updateUserProfile(@AuthenticationPrincipal @Valid CustomUserDetails currentUser + , @RequestBody @Valid UserReq req) { + UserRes res = userService.userProfileUpdate(currentUser.getId(), req); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(UserSuccessCode.UPDATE_MY_PROFILE_SUCCESS, res)); + } + + + // 3. 회원 탈퇴 (소프트 삭제) + @DeleteMapping("/soft") + @Operation(summary = "유저 프로필 소프트 삭제", description = "로그인한 사용자의 프로필을 임시 삭제합니다.", tags = {"User"}) + public ResponseEntity> softDeleteProfile(@AuthenticationPrincipal CustomUserDetails currentUser) { + UserSoftDeleteRes res = userService.softDeleteProfile(currentUser.getId()); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(UserSuccessCode.SOFT_DELETE_MY_PROFILE_SUCCESS, res)); + } + + // 4. 유저 하드 삭제 + @DeleteMapping("/hard") + @Operation(summary = "유저 프로필 하드 삭제", description = "로그인한 사용자의 프로필을 완전히 삭제합니다.", tags = {"User"}) + public ResponseEntity> hardDeleteUser(@AuthenticationPrincipal CustomUserDetails currentUser) { + if (adminManager.isAdmin(currentUser.getAuthorities())) { + userService.hardDeleteUserProfile(currentUser.getId()); + } + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(UserSuccessCode.GET_MY_PROFILE_SUCCESS, null)); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/controller/subscribe/CuratorSubscribeController.java b/src/main/java/BookPick/mvp/domain/user/controller/subscribe/CuratorSubscribeController.java new file mode 100644 index 0000000..12052db --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/controller/subscribe/CuratorSubscribeController.java @@ -0,0 +1,62 @@ +package BookPick.mvp.domain.user.controller.subscribe; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.user.dto.subscribe.CuratorSubscribeReq; +import BookPick.mvp.domain.user.dto.subscribe.CuratorSubscribeRes; +import BookPick.mvp.domain.user.dto.subscribe.SubscribedCuratorPageRes; +import BookPick.mvp.domain.user.enums.curator.CuratorSuccessCode; +import BookPick.mvp.domain.user.service.subscribe.CurationSubscribeService; +import BookPick.mvp.domain.user.util.CurrentUserCheck; +import BookPick.mvp.global.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class CuratorSubscribeController { + + private final CurrentUserCheck currentUserCheck; + private final CurationSubscribeService curationSubscribeService; + + // 1. 큐레이터 구독하기 + + + @PostMapping("/subscribe") + @Operation(summary = "큐레이터 구독/취소", description = "큐레이터 구독 버튼을 누릅니다. 이미 구독된 상태라면 구독이 취소됩니다.", tags = {"Subscribe"}) + public ResponseEntity> subscribe( + @RequestBody @Valid CuratorSubscribeReq req, + @AuthenticationPrincipal CustomUserDetails currentUser){ + + currentUserCheck.validateLoginUser(currentUser); + + CuratorSubscribeRes curatorSubscribeRes = curationSubscribeService.subscribe(currentUser.getId(), req); + + if (curatorSubscribeRes.subscribed()) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(CuratorSuccessCode.CURATOR_SUBSCRIBE_SUCCESS, curatorSubscribeRes)); + } else { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(CuratorSuccessCode.CURATOR_SUBSCRIBE_CANCLE_SUCCESS, curatorSubscribeRes)); + } + } + + + @Operation(summary = "큐레이터 구독 리스트 제공", description = "사용자가 구독한 큐레이터 리스트를 제공합니다.", tags = {"Subscribe"}) + @GetMapping("/subscribe/curators") + public ResponseEntity> getSubscribedCurators( + @AuthenticationPrincipal @Valid CustomUserDetails currentUser, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + + currentUserCheck.validateLoginUser(currentUser); + SubscribedCuratorPageRes subscribedCuratorPageRes = curationSubscribeService.getSubscribedCurators(currentUser.getId(), page, size); + return ResponseEntity.ok() + .body(ApiResponse.success(CuratorSuccessCode.GET_CURATOR_SUBSCRIBE_LIST_SUCCESS, subscribedCuratorPageRes)); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/user/dto/base/UserReq.java b/src/main/java/BookPick/mvp/domain/user/dto/base/UserReq.java new file mode 100644 index 0000000..736e195 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/dto/base/UserReq.java @@ -0,0 +1,16 @@ +package BookPick.mvp.domain.user.dto.base; + +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.user.entity.User; + +public record UserReq( + Long id, + String email, + String passWord, + String nickName, + String profileImage, + String introduction, + Roles role +) { + +} diff --git a/src/main/java/BookPick/mvp/domain/user/dto/base/UserRes.java b/src/main/java/BookPick/mvp/domain/user/dto/base/UserRes.java new file mode 100644 index 0000000..6a5c3f5 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/dto/base/UserRes.java @@ -0,0 +1,34 @@ +package BookPick.mvp.domain.user.dto.base; + +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.user.entity.User; + +import java.time.LocalDateTime; + +public record UserRes( + Long userId, + String email, + String nickName, + String profileImage, + String introduction, + Roles role, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt, + Boolean deleted +) { + public static UserRes from(User user) { + return new UserRes( + user.getId(), + user.getEmail(), + user.getNickname(), + user.getProfileImageUrl(), + user.getBio(), + user.getRole(), + user.getCreatedAt(), + user.getUpdatedAt(), + user.getDeletedAt(), + user.isDeleted() + ); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/dto/base/delete/UserSoftDeleteRes.java b/src/main/java/BookPick/mvp/domain/user/dto/base/delete/UserSoftDeleteRes.java new file mode 100644 index 0000000..a224c01 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/dto/base/delete/UserSoftDeleteRes.java @@ -0,0 +1,20 @@ +package BookPick.mvp.domain.user.dto.base.delete; + + +import BookPick.mvp.domain.user.entity.User; + +import java.time.LocalDateTime; + +public record UserSoftDeleteRes( + Long userId, + Boolean deleted, + LocalDateTime deletedAt +) { + public static UserSoftDeleteRes from(User user) { + return new UserSoftDeleteRes( + user.getId(), + user.isDeleted(), + user.getDeletedAt() + ); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/dto/passWord/PassWordChangeReq.java b/src/main/java/BookPick/mvp/domain/user/dto/passWord/PassWordChangeReq.java new file mode 100644 index 0000000..83aefea --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/dto/passWord/PassWordChangeReq.java @@ -0,0 +1,19 @@ +package BookPick.mvp.domain.user.dto.passWord; + +import jakarta.validation.constraints.NotNull; + +public record PassWordChangeReq( + + @NotNull + String nowPassWord, + + @NotNull + String newPassWord, + + @NotNull + String confirmPassword +) { + public static PassWordChangeReq from(String nowPassWord, String newPassWord, String confirmPassword){ + return new PassWordChangeReq(nowPassWord, newPassWord, confirmPassword); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/dto/passWord/PassWordChangeRes.java b/src/main/java/BookPick/mvp/domain/user/dto/passWord/PassWordChangeRes.java new file mode 100644 index 0000000..fda1940 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/dto/passWord/PassWordChangeRes.java @@ -0,0 +1,19 @@ +package BookPick.mvp.domain.user.dto.passWord; + +import jakarta.validation.constraints.NotNull; + +public record PassWordChangeRes( + + @NotNull + String nowPassWord, + + @NotNull + String newPassWord, + + @NotNull + String confirmPassword +) { + public static PassWordChangeRes from(String nowPassWord, String newPassWord, String confirmPassword){ + return new PassWordChangeRes(nowPassWord, newPassWord, confirmPassword); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/dto/subscribe/CuratorSubscribeReq.java b/src/main/java/BookPick/mvp/domain/user/dto/subscribe/CuratorSubscribeReq.java new file mode 100644 index 0000000..b1238df --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/dto/subscribe/CuratorSubscribeReq.java @@ -0,0 +1,8 @@ +package BookPick.mvp.domain.user.dto.subscribe; + +public record CuratorSubscribeReq( + Long curatorId +) { + + +} diff --git a/src/main/java/BookPick/mvp/domain/user/dto/subscribe/CuratorSubscribeRes.java b/src/main/java/BookPick/mvp/domain/user/dto/subscribe/CuratorSubscribeRes.java new file mode 100644 index 0000000..09cdec5 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/dto/subscribe/CuratorSubscribeRes.java @@ -0,0 +1,19 @@ +package BookPick.mvp.domain.user.dto.subscribe; + +import BookPick.mvp.domain.user.entity.CuratorSubscribe; +import BookPick.mvp.domain.user.entity.User; + +public record CuratorSubscribeRes( + Long curatorId, + boolean subscribed +){ + + + public static CuratorSubscribeRes from(User curator, boolean subscribed){ + return new CuratorSubscribeRes(curator.getId(),subscribed); + } + + public static CuratorSubscribeRes from(CuratorSubscribe curationSubscribe, boolean subscribed){ + return new CuratorSubscribeRes(curationSubscribe.getCurator().getId(),subscribed); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/dto/subscribe/SubscribedCuratorPageRes.java b/src/main/java/BookPick/mvp/domain/user/dto/subscribe/SubscribedCuratorPageRes.java new file mode 100644 index 0000000..f62763b --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/dto/subscribe/SubscribedCuratorPageRes.java @@ -0,0 +1,19 @@ +package BookPick.mvp.domain.user.dto.subscribe; + +import BookPick.mvp.global.dto.PageInfo; +import lombok.Builder; + +import java.util.List; + +@Builder +public record SubscribedCuratorPageRes( + List curators, + PageInfo pageInfo +) { + public static SubscribedCuratorPageRes of(List curators, PageInfo pageInfo) { + return SubscribedCuratorPageRes.builder() + .curators(curators) + .pageInfo(pageInfo) + .build(); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/dto/subscribe/SubscribedCuratorRes.java b/src/main/java/BookPick/mvp/domain/user/dto/subscribe/SubscribedCuratorRes.java new file mode 100644 index 0000000..50acbfd --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/dto/subscribe/SubscribedCuratorRes.java @@ -0,0 +1,21 @@ +package BookPick.mvp.domain.user.dto.subscribe; + +import BookPick.mvp.domain.user.entity.User; +import lombok.Builder; + +@Builder +public record SubscribedCuratorRes( + Long curatorId, + String nickname, + String profileImageUrl, + String bio +) { + public static SubscribedCuratorRes from(User curator) { + return SubscribedCuratorRes.builder() + .curatorId(curator.getId()) + .nickname(curator.getNickname()) + .profileImageUrl(curator.getProfileImageUrl()) + .bio(curator.getBio()) + .build(); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/entity/CuratorSubscribe.java b/src/main/java/BookPick/mvp/domain/user/entity/CuratorSubscribe.java new file mode 100644 index 0000000..2e321dd --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/entity/CuratorSubscribe.java @@ -0,0 +1,37 @@ +package BookPick.mvp.domain.user.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table( + name = "curator_subscribe", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_user_curator", + columnNames = {"user_id", "curator_id"} + ) + } +) +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +@Setter +public class CuratorSubscribe { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "curator_id") + private User curator; +} + + + diff --git a/src/main/java/BookPick/mvp/domain/user/entity/User.java b/src/main/java/BookPick/mvp/domain/user/entity/User.java new file mode 100644 index 0000000..fad140b --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/entity/User.java @@ -0,0 +1,78 @@ +package BookPick.mvp.domain.user.entity; + +import BookPick.mvp.domain.auth.Roles; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User { + + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_Id") + private Long id; // 내부 식별자 (PK) + + @Column(name = "login_email", nullable = false, unique = true, length = 255) + @Email(message = "올바른 이메일 형식이여야 합니다.") + private String email; // 로그인 ID, 고유 + + + @Column(name = "login_password", nullable = false, length = 255) + private String password; // 비밀번호 해시 + + @Column(length = 50) + @Size(min = 2, max = 10, message = "닉네임은 2~10자여야 합니다.") + private String nickname; // 프로필 닉네임 + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private Roles role; // ROLE_USER, ROLE_ADMIN 등 + + @Column(length = 255) + @Size(message = "자기소개는 255자 이하여야 합니다.") + private String bio; // 자기소개 문구 + + @Column(name = "profile_image_url", length = 500) + private String profileImageUrl; // 프로필 사진 경로 + + @Column(name = "is_first_login", nullable = false) + @Builder.Default + private boolean isFirstLogin = true; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; // 생성 시각 + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; // 수정 시각 + + @Column(name = "deleted") + private boolean deleted = false; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; // 삭제 시각 + + + + + public void isNotFirstLogin() { + this.isFirstLogin = false; + } + + +} diff --git a/src/main/java/BookPick/mvp/domain/user/enums/curator/CuratorErrorCode.java b/src/main/java/BookPick/mvp/domain/user/enums/curator/CuratorErrorCode.java new file mode 100644 index 0000000..3c3e6a4 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/enums/curator/CuratorErrorCode.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.user.enums.curator; + +import BookPick.mvp.global.enums.ErrorCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum CuratorErrorCode implements ErrorCodeInterface { + + // -- Curator -- + Curator_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저를 찾을 수 없습니다."); //404 + + private final HttpStatus status; + private final String message; + +} diff --git a/src/main/java/BookPick/mvp/domain/user/enums/curator/CuratorSuccessCode.java b/src/main/java/BookPick/mvp/domain/user/enums/curator/CuratorSuccessCode.java new file mode 100644 index 0000000..d4a54bd --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/enums/curator/CuratorSuccessCode.java @@ -0,0 +1,21 @@ +package BookPick.mvp.domain.user.enums.curator; + +import BookPick.mvp.global.enums.SuccessCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum CuratorSuccessCode implements SuccessCodeInterface { + + + CURATOR_SUBSCRIBE_SUCCESS(HttpStatus.CREATED, "큐레이션 구독을 성공적으로 실행하였습니다."), + CURATOR_SUBSCRIBE_CANCLE_SUCCESS(HttpStatus.OK, "큐레이션 구독 취소를 성공적으로 실행하였습니다."), + + GET_CURATOR_SUBSCRIBE_LIST_SUCCESS(HttpStatus.OK, "큐레이터 구독 리스트를 성공적으로 조회하였습니다."); + + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/BookPick/mvp/domain/user/enums/user/UserErrorCode.java b/src/main/java/BookPick/mvp/domain/user/enums/user/UserErrorCode.java new file mode 100644 index 0000000..bf5e1ed --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/enums/user/UserErrorCode.java @@ -0,0 +1,23 @@ +package BookPick.mvp.domain.user.enums.user; + +import BookPick.mvp.global.enums.ErrorCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum UserErrorCode implements ErrorCodeInterface { + + // -- User -- + User_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저를 찾을 수 없습니다."), //404 + NOT_HAVE_ADMIN_ROLE(HttpStatus.BAD_REQUEST, "관리자 권한이 없는 유저입니다."), + USER_NAME_IS_NULL(HttpStatus.BAD_REQUEST, "닉네임은 비어있으면 안됩니다."), + ALREADY_DELETE_USR(HttpStatus.CONFLICT, "이미 삭제한 유저입니다."), //409, 이미 삭제한 유저기때문에 conflict 충돌이라는 의미 + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "변경할 비밀번호와 확인 비밀번호가 일치하지 않습니다."), //409, 이미 삭제한 유저기때문에 conflict 충돌이라는 의미 + WRONG_CURRENT_PASSWORD(HttpStatus.UNAUTHORIZED, "현재 비밀번호가 올바르지 않습니다."); + + private final HttpStatus status; + private final String message; + +} diff --git a/src/main/java/BookPick/mvp/domain/user/enums/user/UserRole.java b/src/main/java/BookPick/mvp/domain/user/enums/user/UserRole.java new file mode 100644 index 0000000..8c4e65d --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/enums/user/UserRole.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.user.enums.user; + +import lombok.Getter; + +@Getter +public enum UserRole { + ROLE_USER("user"), + ROLE_ADMIN("admin"); + + private final String description; + + UserRole(String description) { + this.description = description; + } + + + ; +} diff --git a/src/main/java/BookPick/mvp/domain/user/enums/user/UserSuccessCode.java b/src/main/java/BookPick/mvp/domain/user/enums/user/UserSuccessCode.java new file mode 100644 index 0000000..928bc84 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/enums/user/UserSuccessCode.java @@ -0,0 +1,22 @@ +package BookPick.mvp.domain.user.enums.user; + +import BookPick.mvp.global.enums.SuccessCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum UserSuccessCode implements SuccessCodeInterface { + + CREATE_USER_SUCCESS(HttpStatus.CREATED, "사용자 생성을 성공하였습니다."), + GET_USERS_SUCCESS(HttpStatus.OK, "사용자 목록 조회를 성공하였습니다."), + GET_MY_PROFILE_SUCCESS(HttpStatus.OK, "사용자 프로필 조회를 성공하였습니다."), + UPDATE_MY_PROFILE_SUCCESS(HttpStatus.OK, "사용자 프로필 수정을 성공하였습니다."), + SOFT_DELETE_MY_PROFILE_SUCCESS(HttpStatus.OK, "사용자 임시 삭제를 성공하였습니다."), + HARD_DELETE_MY_PROFILE_SUCCESS(HttpStatus.OK, "사용자 완전 삭제를 성공하였습니다."), + PASSWORD_CHANGE_SUCCESS(HttpStatus.OK, "비밀번호 변경을 성공하였습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/BookPick/mvp/domain/user/exception/common/AlreadyDeletedException.java b/src/main/java/BookPick/mvp/domain/user/exception/common/AlreadyDeletedException.java new file mode 100644 index 0000000..beb5098 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/exception/common/AlreadyDeletedException.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.user.exception.common; + +import BookPick.mvp.domain.user.enums.user.UserErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class AlreadyDeletedException extends BusinessException { + public AlreadyDeletedException(){ + super(UserErrorCode.ALREADY_DELETE_USR); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/exception/common/NotHaveAdminRole.java b/src/main/java/BookPick/mvp/domain/user/exception/common/NotHaveAdminRole.java new file mode 100644 index 0000000..e0114cd --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/exception/common/NotHaveAdminRole.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.user.exception.common; + +import BookPick.mvp.domain.user.enums.user.UserErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class NotHaveAdminRole extends BusinessException { + public NotHaveAdminRole(){ + super(UserErrorCode.NOT_HAVE_ADMIN_ROLE); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/exception/common/PasswordMismatchException.java b/src/main/java/BookPick/mvp/domain/user/exception/common/PasswordMismatchException.java new file mode 100644 index 0000000..3e46c06 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/exception/common/PasswordMismatchException.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.user.exception.common; + +import BookPick.mvp.domain.user.enums.user.UserErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class PasswordMismatchException extends BusinessException { + public PasswordMismatchException() { + super(UserErrorCode.PASSWORD_MISMATCH); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/exception/common/UserNotFoundException.java b/src/main/java/BookPick/mvp/domain/user/exception/common/UserNotFoundException.java new file mode 100644 index 0000000..c0e1800 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/exception/common/UserNotFoundException.java @@ -0,0 +1,11 @@ +package BookPick.mvp.domain.user.exception.common; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class UserNotFoundException extends BusinessException { + public UserNotFoundException(){ + super(ErrorCode.User_NOT_FOUND); + } + +} diff --git a/src/main/java/BookPick/mvp/domain/user/exception/common/WrongCurrentPasswordException.java b/src/main/java/BookPick/mvp/domain/user/exception/common/WrongCurrentPasswordException.java new file mode 100644 index 0000000..211b9fb --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/exception/common/WrongCurrentPasswordException.java @@ -0,0 +1,11 @@ +package BookPick.mvp.domain.user.exception.common; + + +import BookPick.mvp.domain.user.enums.user.UserErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class WrongCurrentPasswordException extends BusinessException { + public WrongCurrentPasswordException(){ + super(UserErrorCode.WRONG_CURRENT_PASSWORD); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/exception/curator/CuratorNotFoundException.java b/src/main/java/BookPick/mvp/domain/user/exception/curator/CuratorNotFoundException.java new file mode 100644 index 0000000..617814c --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/exception/curator/CuratorNotFoundException.java @@ -0,0 +1,11 @@ +package BookPick.mvp.domain.user.exception.curator; + +import BookPick.mvp.domain.user.enums.curator.CuratorErrorCode; +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class CuratorNotFoundException extends BusinessException { + public CuratorNotFoundException(){ + super(CuratorErrorCode.Curator_NOT_FOUND); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/exception/profile/UserNameNotNullException.java b/src/main/java/BookPick/mvp/domain/user/exception/profile/UserNameNotNullException.java new file mode 100644 index 0000000..9db3f3a --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/exception/profile/UserNameNotNullException.java @@ -0,0 +1,10 @@ +package BookPick.mvp.domain.user.exception.profile; + +import BookPick.mvp.domain.user.enums.user.UserErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class UserNameNotNullException extends BusinessException { + public UserNameNotNullException(){ + super(UserErrorCode.USER_NAME_IS_NULL); + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/exception/subscribe/SelfSubscribeDeniedException.java b/src/main/java/BookPick/mvp/domain/user/exception/subscribe/SelfSubscribeDeniedException.java new file mode 100644 index 0000000..61e5288 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/exception/subscribe/SelfSubscribeDeniedException.java @@ -0,0 +1,11 @@ +// SelfSubscribeDeniedException.java +package BookPick.mvp.domain.user.exception.subscribe; + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; + +public class SelfSubscribeDeniedException extends BusinessException { + public SelfSubscribeDeniedException() { + super(ErrorCode.CURATION_ACCESS_DENIED); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/user/repository/UserRepository.java b/src/main/java/BookPick/mvp/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..978fafb --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/repository/UserRepository.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.user.repository; + +import BookPick.mvp.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + Optional findByEmail(String email); + + Object findFirstByEmail(String email); + + boolean existsAllByEmail(String email); +} + diff --git a/src/main/java/BookPick/mvp/domain/user/repository/subscribe/CurationSubscribeRepository.java b/src/main/java/BookPick/mvp/domain/user/repository/subscribe/CurationSubscribeRepository.java new file mode 100644 index 0000000..c20cf20 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/repository/subscribe/CurationSubscribeRepository.java @@ -0,0 +1,18 @@ +package BookPick.mvp.domain.user.repository.subscribe; + +import BookPick.mvp.domain.user.entity.CuratorSubscribe; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CurationSubscribeRepository extends JpaRepository { + + Optional findByUserIdAndCuratorId(Long userId, Long curatorId); + + Page findByUserIdOrderByIdDesc(Long userId, Pageable pageable); + +} diff --git a/src/main/java/BookPick/mvp/domain/user/service/base/UserService.java b/src/main/java/BookPick/mvp/domain/user/service/base/UserService.java new file mode 100644 index 0000000..b5763e3 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/service/base/UserService.java @@ -0,0 +1,107 @@ +package BookPick.mvp.domain.user.service.base; + +import BookPick.mvp.domain.user.dto.base.UserReq; +import BookPick.mvp.domain.user.dto.base.UserRes; +import BookPick.mvp.domain.user.dto.base.delete.UserSoftDeleteRes; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.AlreadyDeletedException; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.exception.profile.UserNameNotNullException; +import BookPick.mvp.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class UserService { + final private UserRepository userRepository; + + // 1. 유저 생성 (관리자 권한) + public UserRes CreateUser(UserReq req) { + + User user = User.builder() + .email(req.email()) + .password(req.passWord()) + .nickname(req.nickName()) + .profileImageUrl(req.profileImage()) + .role(req.role()) + .build(); + + User result = userRepository.save(user); + + + return UserRes.from(result); + } + + + + // 2.1 개인 정보 조회 (개인 권한) + public UserRes userProfileGet(Long userId) { + + User result = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + return UserRes.from(result); + } + + + + // 3.1 유저 수정 + @Transactional + public UserRes userProfileUpdate(Long userId, UserReq req) { + + if (userId == null) { + throw new UserNotFoundException(); + } + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + // 더티 체킹 + if (req.email() != null) user.setEmail(req.email()); + if (req.nickName() == null) { + throw new UserNameNotNullException(); + } + user.setNickname(req.nickName()); + if (req.profileImage() != null) user.setProfileImageUrl(req.profileImage()); + if (req.introduction() != null) user.setBio(req.introduction()); + + return UserRes.from(user); + + } + + + + // 4.1 유저 소프트 삭제 + @Transactional + public UserSoftDeleteRes softDeleteProfile(Long userId) { + + // 델리트 1로 바꾸고 삭제 시간 업데이트 + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + if (user.isDeleted()) { + throw new AlreadyDeletedException(); + } + + user.setDeleted(true); + user.setDeletedAt(LocalDateTime.now()); + + return UserSoftDeleteRes.from(user); + } + // 4.2 유저 하드 삭제 + @Transactional + public void hardDeleteUserProfile(Long userId) { + + // 델리트 1로 바꾸고 삭제 시간 업데이트 + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + userRepository.deleteById(userId); + + } + + +} diff --git a/src/main/java/BookPick/mvp/domain/user/service/passWord/PassWordService.java b/src/main/java/BookPick/mvp/domain/user/service/passWord/PassWordService.java new file mode 100644 index 0000000..1f72da7 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/service/passWord/PassWordService.java @@ -0,0 +1,48 @@ +package BookPick.mvp.domain.user.service.passWord; + +import BookPick.mvp.domain.user.dto.passWord.PassWordChangeReq; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.PasswordMismatchException; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.exception.common.WrongCurrentPasswordException; +import BookPick.mvp.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PassWordService { + final private UserRepository userRepository; + final private PasswordEncoder passwordEncoder; + + + // 1. 비밀번호 변경 + // TODO 1. 자동입력 방지 문자 추가 + @Transactional + public void PassWordChange(Long userId, PassWordChangeReq req) { + if (userId == null) { + throw new UserNotFoundException(); + } + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + // 1) 새로운 비밀번호 컨펌 비밀번호 체크 + if (!req.newPassWord().equals(req.confirmPassword())) { + throw new PasswordMismatchException(); + } + + // 2) 입력받은 비밀번호와 기존 비밀번호 체크 + if (!passwordEncoder.matches(req.nowPassWord(), user.getPassword())) { + throw new WrongCurrentPasswordException(); + } + + // 3) 기존 비밀번호를 입력받은 비밀번호로 변경 + user.setPassword(passwordEncoder.encode(req.newPassWord())); + + + } + + +} diff --git a/src/main/java/BookPick/mvp/domain/user/service/subscribe/CurationSubscribeService.java b/src/main/java/BookPick/mvp/domain/user/service/subscribe/CurationSubscribeService.java new file mode 100644 index 0000000..7a83589 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/service/subscribe/CurationSubscribeService.java @@ -0,0 +1,93 @@ +package BookPick.mvp.domain.user.service.subscribe; + +import BookPick.mvp.domain.user.dto.subscribe.CuratorSubscribeReq; +import BookPick.mvp.domain.user.dto.subscribe.CuratorSubscribeRes; +import BookPick.mvp.domain.user.dto.subscribe.SubscribedCuratorPageRes; +import BookPick.mvp.domain.user.dto.subscribe.SubscribedCuratorRes; +import BookPick.mvp.domain.user.entity.CuratorSubscribe; +import BookPick.mvp.domain.user.exception.curator.CuratorNotFoundException; +import BookPick.mvp.domain.user.repository.subscribe.CurationSubscribeRepository; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.global.dto.PageInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.swing.text.html.Option; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CurationSubscribeService { + private final CurationSubscribeRepository curationSubscribeRepository; + private final UserRepository userRepository; + + + // 1. 큐레이터 구독 + @Transactional + public CuratorSubscribeRes subscribe(Long userId, CuratorSubscribeReq req) { + + + // 2. 구독 신청한 유저 얻기 + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + // 3. 구독할 유저 얻기 + User curator = userRepository.findById(req.curatorId()) + .orElseThrow(CuratorNotFoundException::new); + + // 4. 해당 유저가 구독했는지 체크 + // 3.1 유저의 큐레이션 구독 정보 가져오기 + Optional opt = curationSubscribeRepository.findByUserIdAndCuratorId(userId, req.curatorId()); + + // 3.2 구독 정보가 없으면 생성 + if (opt.isEmpty()) { + CuratorSubscribe curationSubscribe = CuratorSubscribe.builder() + .user(user) + .curator(curator) + .build(); + + curationSubscribeRepository.save(curationSubscribe); + + return CuratorSubscribeRes.from(curationSubscribe , true); + } + + // 3.2 구독 정보가 있으면 삭제 + else { + curationSubscribeRepository.delete(opt.get()); + + return CuratorSubscribeRes.from(curator , false); + } + } + + @Transactional(readOnly = true) + public boolean isSubscribeCurator(Long userId, Long CuratorId){ + Optional subInfo = curationSubscribeRepository.findByUserIdAndCuratorId(userId,CuratorId); + if(subInfo.isPresent()){ + return true; + } + return false; + } + + // 3. 큐레이터 구독 리스트 반환 + @Transactional(readOnly = true) + public SubscribedCuratorPageRes getSubscribedCurators(Long userId, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page subscribesPage = curationSubscribeRepository.findByUserIdOrderByIdDesc(userId, pageable); + + List content = subscribesPage.getContent().stream() + .map(subscribe -> SubscribedCuratorRes.from(subscribe.getCurator())) + .collect(Collectors.toList()); + + PageInfo pageInfo = PageInfo.of(subscribesPage); + + return SubscribedCuratorPageRes.of(content, pageInfo); + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/domain/user/util/AdminManager.java b/src/main/java/BookPick/mvp/domain/user/util/AdminManager.java new file mode 100644 index 0000000..ba69444 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/util/AdminManager.java @@ -0,0 +1,20 @@ +package BookPick.mvp.domain.user.util; + +import BookPick.mvp.domain.auth.Roles; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.Collection; + +@Component +public class AdminManager { + + public boolean isAdmin(Collection authorities){ + for (GrantedAuthority authority : authorities){ + if(authority.getAuthority().equals(Roles.ROLE_ADMIN.name())){ + return true; + } + } + return false; + } +} diff --git a/src/main/java/BookPick/mvp/domain/user/util/CurrentUserCheck.java b/src/main/java/BookPick/mvp/domain/user/util/CurrentUserCheck.java new file mode 100644 index 0000000..3f00171 --- /dev/null +++ b/src/main/java/BookPick/mvp/domain/user/util/CurrentUserCheck.java @@ -0,0 +1,15 @@ +package BookPick.mvp.domain.user.util; + +import BookPick.mvp.domain.auth.exception.NotAuthenticateUser; +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import org.springframework.stereotype.Component; + +@Component +public class CurrentUserCheck { + + public void validateLoginUser(CustomUserDetails currentUser) { + if (currentUser == null) { + throw new NotAuthenticateUser(); + } + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/global/HyperParam/Defaults.java b/src/main/java/BookPick/mvp/global/HyperParam/Defaults.java new file mode 100644 index 0000000..bdd0c75 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/HyperParam/Defaults.java @@ -0,0 +1,27 @@ +package BookPick.mvp.global.HyperParam; + +import BookPick.mvp.domain.curation.enums.common.SortType; +import lombok.AllArgsConstructor; +import lombok.Getter; + + +@AllArgsConstructor +@Getter +public enum Defaults { + SORT_TYPE(SortType.SORT_LATEST), + PAGE_SIZE(20); + + private Object value; + public static final String SORT_TYPE_STRING = SORT_TYPE.getValueAsString(); // 상수 정의 + + + public Object getValue() { + return value; + } + public String getValueAsString() { // 문자열로 변환해주는 메서드 + if (value instanceof Enum) { + return ((Enum) value).name(); // Enum이면 name() 사용 + } + return String.valueOf(value); // 그 외에는 그냥 toString + } +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/global/api/ApiResponse.java b/src/main/java/BookPick/mvp/global/api/ApiResponse.java new file mode 100644 index 0000000..2c12ad6 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/api/ApiResponse.java @@ -0,0 +1,51 @@ +package BookPick.mvp.global.api; + + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import BookPick.mvp.global.enums.ErrorCodeInterface; +import BookPick.mvp.global.enums.SuccessCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.springframework.http.HttpStatus; + +@Getter +@Setter +@AllArgsConstructor +public class ApiResponse { + private int status; // ex. DUPLICATE_EMIAL (개발용 친화) + private String message; // ex. 이미 존재하는 이메일입니다. (사람용 친화) + private T data; + + // -- Success -- + public static ApiResponse success(SuccessCode successMessage, T data) { // 를 반환 타입 앞에 써주는 이유 : 컴파일시 해당 메서드의 반환 타입을 알기 위해서, 런타임시 파라미터로 들어온 T를 static 상황(컴파일 상황)에서는 모르기 때문이다. 컴퓨일이 런타임 이전에 일어나기 때문에 + return new ApiResponse(successMessage.getStatus().value(), successMessage.getMessage(), data); + } + public static ApiResponse success(SuccessCodeInterface successMessage, T data) { // 를 반환 타입 앞에 써주는 이유 : 컴파일시 해당 메서드의 반환 타입을 알기 위해서, 런타임시 파라미터로 들어온 T를 static 상황(컴파일 상황)에서는 모르기 때문이다. 컴퓨일이 런타임 이전에 일어나기 때문에 + return new ApiResponse(successMessage.getStatus().value(), successMessage.getMessage(), data); + } + + // -- Error -- + public static ApiResponse error(ErrorCode errorCode) { + return new ApiResponse( + errorCode.getStatus().value(), + errorCode.getMessage(), // @Valid 같은 데서 넘어온 메시지 + null + ); + } + public static ApiResponse error(ErrorCodeInterface errorCode) { + return new ApiResponse( + errorCode.getStatus().value(), + errorCode.getMessage(), // @Valid 같은 데서 넘어온 메시지 + null + ); + } + public static ApiResponse customError(HttpStatus httpStatus, String message, T data) { + return new ApiResponse( + httpStatus.value(), + message, // @Valid 같은 데서 넘어온 메시지 + data + ); + } +} diff --git a/src/main/java/BookPick/mvp/global/api/DuplicateResourceException.java b/src/main/java/BookPick/mvp/global/api/DuplicateResourceException.java new file mode 100644 index 0000000..9fcfe65 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/api/DuplicateResourceException.java @@ -0,0 +1,18 @@ +package BookPick.mvp.global.api; + + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.exception.BusinessException; +import lombok.Getter; +import lombok.Setter; + + +@Getter +@Setter +public class DuplicateResourceException extends BusinessException { + ErrorCode errorCode; + + public DuplicateResourceException(ErrorCode errorCode){ + super(ErrorCode.DUPLICATE_EMAIL); // Throwable의 detailMessage에 message 저장 + } +} diff --git a/src/main/java/BookPick/mvp/global/api/ErrorCode/ErrorCode.java b/src/main/java/BookPick/mvp/global/api/ErrorCode/ErrorCode.java new file mode 100644 index 0000000..87d087d --- /dev/null +++ b/src/main/java/BookPick/mvp/global/api/ErrorCode/ErrorCode.java @@ -0,0 +1,49 @@ +package BookPick.mvp.global.api.ErrorCode; + +import BookPick.mvp.global.enums.ErrorCodeInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum ErrorCode implements ErrorCodeInterface { + + // -- Auth + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), // 400 회원 가입 + DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 존재하는 이메일 입니다."), // 409 회원 가입 + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 잘 못 되었습니다."), // 401 로그인 + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), + Token_Expired(HttpStatus.UNAUTHORIZED, "로그인 토큰이 만료되었습니다. 다시 로그인해주세요."), + Invalid_Token_Type(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰 형식입니다. 다시 로그인해주세요."), + + // -- JWT -- + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "로그인 토큰이 만료되었습니다. 다시 로그인해주세요."), + INVALID_TOKEN_TYPE(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰 형식입니다. 다시 로그인해주세요."), + TOKEN_LOGOUTED(HttpStatus.UNAUTHORIZED, "이미 로그아웃된 토큰입니다."), + + + // -- User -- + User_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저를 찾을 수 없습니다."), //404 + + // -- Curation -- + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 큐레이션을 찾을 수 없습니다."), //404 + + + // -- Comment -- + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 댓글 찾을 수 없습니다."), //404 + PARENTS_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 댓글의 부모 댓글을 찾을 수 없습니다."), //404 + + // -- Reading Preference -- + READING_PREFERENCE_ALREADY_RESiGSTER(HttpStatus.CONFLICT, "이미 독서 취향이 존재합니다."), + READING_PREFERENCE_NOT_EXISTED(HttpStatus.OK, "사용자의 독서 취향이 존재하지 않습니다."), // 독서취향이 존재하지 않더라도 문제가 없어서 OK + + // -- Curation -- + CURATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 큐레이션을 찾을 수 없습니다."), + CURATION_ACCESS_DENIED(HttpStatus.FORBIDDEN, "큐레이션 접근 권한이 없습니다."), + CURATION_ALREADY_PUBLISHED(HttpStatus.BAD_REQUEST, "이미 발행된 큐레이션 입니다."), + CURATION_DRAFT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "임시저장 큐레이션은 작성자만 접근할 수 있습니다."); + + private final HttpStatus status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/global/api/SuccessCode/SuccessCode.java b/src/main/java/BookPick/mvp/global/api/SuccessCode/SuccessCode.java new file mode 100644 index 0000000..8d8f2be --- /dev/null +++ b/src/main/java/BookPick/mvp/global/api/SuccessCode/SuccessCode.java @@ -0,0 +1,49 @@ +package BookPick.mvp.global.api.SuccessCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum SuccessCode { + + // -- Auth -- + REGISTER_SUCCESS(HttpStatus.OK, "회원가입을 성공하였습니다."), + LOGIN_SUCCESS(HttpStatus.OK, "로그인에 성공했습니다"), + TOKEN_REFERSH_SUCCESS(HttpStatus.OK, "액세스 토큰 재발급에 성공하였습니다."), + LOGOUT_SUCCESS(HttpStatus.OK, "로그아웃에 성공했습니다"), + + // -- User -- + GET_USERS_SUCCESS(HttpStatus.OK, "사용자 목록 조회를 성공하였습니다."), + + // -- Comment -- + COMMENT_CREATE_SUCCESS(HttpStatus.CREATED, "댓글을 성공적으로 등록하였습니다."), + COMMENT_LIST_READ_SUCCESS(HttpStatus.OK, "댓글 목록을 성공적으로 조회하였습니다."), + COMMENT_LIST_EMPTY(HttpStatus.OK, "댓글이 없습니다."), + COMMENT_READ_SUCCESS(HttpStatus.OK, "댓글을 성공적으로 조회하였습니다."), + COMMENT_UPDATE_SUCCESS(HttpStatus.OK, "댓글을 성공적으로 수정하였습니다."), + COMMENT_DELETE_SUCCESS(HttpStatus.OK, "댓글을 성공적으로 삭제하였습니다."), + + // -- Book -- + BOOK_LIST_READ_SUCCESS(HttpStatus.OK, "책 목록을 성공적으로 조회하였습니다."), + BOOK_LINK_READ_SUCCESS(HttpStatus.OK, "책 구매 링크를 성공적으로 조회하였습니다."), + READING_PREFERENCE_REGISTER_SUCCESS(HttpStatus.CREATED, "독서 취향을 성공적으로 등록하였습니다."), + + // -- Reading Preference -- + READING_PREFERENCE_READ_SUCCESS(HttpStatus.CREATED, "독서 취향을 성공적으로 조회하였습니다."), + READING_PREFERENCE_UPDATE_SUCCESS(HttpStatus.CREATED, "독서 취향을 성공적으로 수정하였습니다."), + READING_PREFERENCE_DELETE_SUCCESS(HttpStatus.CREATED, "독서 취향을 성공적으로 삭제하였습니다."), + + // -- Curation -- + CURATION_PUBLISH_SUCCESS(HttpStatus.CREATED, "큐레이션을 성공적으로 발행하였습니다."), + DRAFTED_CURATION_PUBLISH_SUCCESS(HttpStatus.OK, "임시저장 된 큐레이션을 성공적으로 발행하였습니다."), + CURATION_DRAFT_SUCCESS(HttpStatus.CREATED, "큐레이션을 성공적으로 임시저장하였습니다."), + CURATION_DRAFT_UPDATE_SUCCESS(HttpStatus.OK, "큐레이션 임시저장을 성공적으로 수정하였습니다."), + CURATION_GET_SUCCESS(HttpStatus.OK, "큐레이션을 성공적으로 단건 조회하였습니다."), + CURATION_LIST_GET_SUCCESS(HttpStatus.OK, "큐레이션을 성공적으로 리스트 조회하였습니다."), + CURATION_UPDATE_SUCCESS(HttpStatus.OK, "발행된 큐레이션을 성공적으로 수정하였습니다."); + + private final HttpStatus status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/BookPick/mvp/global/config/CacheConfig.java b/src/main/java/BookPick/mvp/global/config/CacheConfig.java new file mode 100644 index 0000000..b1ad222 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/config/CacheConfig.java @@ -0,0 +1,28 @@ +package BookPick.mvp.global.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager("gemini-recommendations"); + + // TTL 설정: 10분 후 자동 삭제 + 최대 1000개 캐시 + cacheManager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(10, TimeUnit.MINUTES) // 10분 후 자동 갱신 + .maximumSize(1000) // 메모리 보호 + .recordStats()); // 캐시 통계 (선택) + + return cacheManager; + } +} diff --git a/src/main/java/BookPick/mvp/global/config/JwtFilter.java b/src/main/java/BookPick/mvp/global/config/JwtFilter.java new file mode 100644 index 0000000..8087909 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/config/JwtFilter.java @@ -0,0 +1,110 @@ +package BookPick.mvp.global.config; + +import BookPick.mvp.domain.auth.exception.InvalidTokenTypeException; +import BookPick.mvp.domain.auth.exception.JwtTokenExpiredException; +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.TokenBlacklistManager; +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.util.JwtUtil; +import BookPick.mvp.security.handler.CustomAuthenticationEntryPoint; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + + private static final String BEARER = "Bearer"; + private final JwtUtil jwtUtil; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final TokenBlacklistManager tokenBlacklistManager; + + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain + ) throws ServletException, IOException { + + + // 1. 이미 인증된 상태면 패스 + if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } + + // 2. 토큰 확인 + String token = resolveAccessToken(request); // 토큰있는지 문자열 체크 + if (token == null) { // 토큰 없으면 그냥 통과 + filterChain.doFilter(request, response); // 넘어가요 + return; + } + + try { + + // 3. 토큰 까서 claims 획득 + Claims claims = jwtUtil.extractAccessToken(token); // 토큰 까기 (토큰 진위여부 검증 해당 메서드에서 진행) + + + // 4. 블랙리스트 체크 + String jti = claims.getId(); + if (jti != null && tokenBlacklistManager.isBlacklisted(jti)) { + request.setAttribute("exception", ErrorCode.TOKEN_LOGOUTED); + customAuthenticationEntryPoint.commence(request, response, new AuthenticationException("Token logged out") { + }); + return; + } + + //5. 블랙리스트 아닐 시, claim 페이로드 값 반환 + Long userId = claims.get("userId", Number.class).longValue(); + String email = claims.get("email").toString(); + var authorities = Arrays.stream( + claims.get("authorities").toString().split(",") + ).map(SimpleGrantedAuthority::new).toList(); + + // 6. 인증 정보 저장 = SecurityContextHolder에 등록 + CustomUserDetails customUserDetails = CustomUserDetails.fromJwt(userId, email, authorities); + var auth = new UsernamePasswordAuthenticationToken( + customUserDetails, null, customUserDetails.getAuthorities()); + auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 추가 정보 저장 (어디서 접속했는지) + SecurityContextHolder.getContext().setAuthentication(auth); + + } catch (JwtTokenExpiredException e) { + request.setAttribute("exception", ErrorCode.TOKEN_EXPIRED); + customAuthenticationEntryPoint.commence(request, response, new AuthenticationException("Token expired") { + }); + return; + } catch (InvalidTokenTypeException e) { + request.setAttribute("exception", ErrorCode.INVALID_TOKEN_TYPE); + customAuthenticationEntryPoint.commence(request, response, new AuthenticationException("Invalid token") { + }); + return; + } + filterChain.doFilter(request, response); + } + + + private String resolveAccessToken(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header != null && header.startsWith(BEARER)) { + return header.substring(BEARER.length()).trim(); + } + return null; + } + +} diff --git a/src/main/java/BookPick/mvp/global/config/SwaggerConfig.java b/src/main/java/BookPick/mvp/global/config/SwaggerConfig.java new file mode 100644 index 0000000..3c11b34 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/config/SwaggerConfig.java @@ -0,0 +1,32 @@ +package BookPick.mvp.global.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.tags.Tag; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components()) + .info(apiInfo()) + // 태그별로 그룹화 + .addTagsItem(new Tag().name("Reading Preference").description("유저 독서 취향 관련 API")) + .addTagsItem(new Tag().name("Curation").description("큐레이션 관련 API")) + .addTagsItem(new Tag().name("Book Search").description("책 검색 관련 API")) + .addTagsItem(new Tag().name("Auth").description("인증 관련 API")) + .addTagsItem(new Tag().name("User").description("유저 관련 API")); + } + + private Info apiInfo() { + return new Info() + .title("Swagger Book Pick ") + .description("블라인드 큐레이션 스토어 API 및 스키마") + .version("1.0.0"); + } +} diff --git a/src/main/java/BookPick/mvp/global/config/WebConfig.java b/src/main/java/BookPick/mvp/global/config/WebConfig.java new file mode 100644 index 0000000..e968b1e --- /dev/null +++ b/src/main/java/BookPick/mvp/global/config/WebConfig.java @@ -0,0 +1,18 @@ +package BookPick.mvp.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins("http://localhost:5173") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); + } +} + diff --git a/src/main/java/BookPick/mvp/global/dto/CursorInfo.java b/src/main/java/BookPick/mvp/global/dto/CursorInfo.java new file mode 100644 index 0000000..a9172c8 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/dto/CursorInfo.java @@ -0,0 +1,11 @@ +package BookPick.mvp.global.dto; + +public record CursorInfo( + boolean hasNext, + Long nextCursor, + int size +) { + public static CursorInfo of(boolean hasNext, Long nextCursor, int size) { + return new CursorInfo(hasNext, nextCursor, size); + } +} diff --git a/src/main/java/BookPick/mvp/global/dto/PageInfo.java b/src/main/java/BookPick/mvp/global/dto/PageInfo.java new file mode 100644 index 0000000..7f1042e --- /dev/null +++ b/src/main/java/BookPick/mvp/global/dto/PageInfo.java @@ -0,0 +1,19 @@ +package BookPick.mvp.global.dto; + +import org.springframework.data.domain.Page; + +public record PageInfo( + int currentPage, + int totalPages, + long totalElements, + boolean hasNext +) { + public static PageInfo of(Page page) { + return new PageInfo( + page.getNumber()+1, + page.getTotalPages(), + page.getTotalElements(), + page.hasNext() + ); + } +} diff --git a/src/main/java/BookPick/mvp/global/enums/ErrorCodeInterface.java b/src/main/java/BookPick/mvp/global/enums/ErrorCodeInterface.java new file mode 100644 index 0000000..f6dc22c --- /dev/null +++ b/src/main/java/BookPick/mvp/global/enums/ErrorCodeInterface.java @@ -0,0 +1,9 @@ +package BookPick.mvp.global.enums; + +import org.springframework.http.HttpStatus; + +public interface ErrorCodeInterface { + HttpStatus getStatus(); + String getMessage(); +} + diff --git a/src/main/java/BookPick/mvp/global/enums/SuccessCodeInterface.java b/src/main/java/BookPick/mvp/global/enums/SuccessCodeInterface.java new file mode 100644 index 0000000..7b5fd34 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/enums/SuccessCodeInterface.java @@ -0,0 +1,9 @@ +package BookPick.mvp.global.enums; + +import org.springframework.http.HttpStatus; + +public interface SuccessCodeInterface { + public HttpStatus getStatus(); + public String getMessage(); + +} diff --git a/src/main/java/BookPick/mvp/global/exception/BusinessException.java b/src/main/java/BookPick/mvp/global/exception/BusinessException.java new file mode 100644 index 0000000..f3053f5 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/exception/BusinessException.java @@ -0,0 +1,15 @@ +package BookPick.mvp.global.exception; + +import BookPick.mvp.global.enums.ErrorCodeInterface; +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException{ + private final ErrorCodeInterface errorCode; + + + public BusinessException(ErrorCodeInterface errorCode){ + super(errorCode.getMessage()); // 나중에 로그 확인을 위해 런타임 예외 디테일 message 에 저장 + this.errorCode=errorCode; + } +} diff --git a/src/main/java/BookPick/mvp/global/exception/DuplicateResourceException.java b/src/main/java/BookPick/mvp/global/exception/DuplicateResourceException.java new file mode 100644 index 0000000..05b5a02 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/exception/DuplicateResourceException.java @@ -0,0 +1,17 @@ +package BookPick.mvp.global.exception; + + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import lombok.Getter; +import lombok.Setter; + + +@Getter +@Setter +public class DuplicateResourceException extends BusinessException{ + ErrorCode errorCode; + + public DuplicateResourceException(ErrorCode errorCode){ + super(ErrorCode.DUPLICATE_EMAIL); // Throwable의 detailMessage에 message 저장 + } +} diff --git a/src/main/java/BookPick/mvp/global/exception/GlobalExceptionHandler.java b/src/main/java/BookPick/mvp/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c6b7a6f --- /dev/null +++ b/src/main/java/BookPick/mvp/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,43 @@ +package BookPick.mvp.global.exception; + + +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import BookPick.mvp.global.enums.ErrorCodeInterface; +import BookPick.mvp.global.enums.SuccessCodeInterface; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException e) { + ErrorCodeInterface errorCode = e.getErrorCode(); + + return ResponseEntity + .status(errorCode.getStatus()) + .body(ApiResponse.error(errorCode)); + } + + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { + + String errorMessage = e.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(fieldError -> fieldError.getDefaultMessage()) // DTO의 @Email(message="...") 내용 + .orElse("잘못된 요청입니다."); + + return ResponseEntity + .status(ErrorCode.INVALID_REQUEST.getStatus()) // 400 + .body(ApiResponse.customError(ErrorCode.INVALID_REQUEST.getStatus(), errorMessage, null)); + } + + + +} diff --git a/src/main/java/BookPick/mvp/global/exception/custom/DuplicateResourceException.java b/src/main/java/BookPick/mvp/global/exception/custom/DuplicateResourceException.java new file mode 100644 index 0000000..5d3901a --- /dev/null +++ b/src/main/java/BookPick/mvp/global/exception/custom/DuplicateResourceException.java @@ -0,0 +1,17 @@ +package BookPick.mvp.global.exception.custom; + + +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import lombok.Getter; +import lombok.Setter; + + +@Getter +@Setter +public class DuplicateResourceException extends RuntimeException{ + ErrorCode errorCode; + + public DuplicateResourceException(){ + super(ErrorCode.DUPLICATE_EMAIL.getMessage()); + } +} diff --git a/src/main/java/BookPick/mvp/global/service/S3Service.java b/src/main/java/BookPick/mvp/global/service/S3Service.java new file mode 100644 index 0000000..d886381 --- /dev/null +++ b/src/main/java/BookPick/mvp/global/service/S3Service.java @@ -0,0 +1,30 @@ +//package BookPick.mvp.global.service; +// +// +//import lombok.RequiredArgsConstructor; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.stereotype.Service; +//import java.time.Duration; +// +//@Service +//@RequiredArgsConstructor +//public class S3Service { +// +// // +// @Value("${spring.cloud.aws.s3.bucket}") //application.properties에 있는 버킷명 가져와서 아래 버킷 변수에 넣어달라는 어노테이션 +// private String bucket; +// private final S3Presigner s3Presigner; +// +// public String createPresignedUrl(String path) { +// var putObjectRequest = PutObjectRequest.builder() +// .bucket(bucket) // 올릴 버킷명 +// .key(path) // 경로 +// .build(); +// var preSignRequest = PutObjectPresignRequest.builder() +// .signatureDuration(Duration.ofMinutes(3)) //url 유효기간 +// .putObjectRequest(putObjectRequest) +// .build(); +// return s3Presigner.presignPutObject(preSignRequest).url().toString(); //presigned url Return +// } +// +//} diff --git a/src/main/java/BookPick/mvp/global/util/JwtUtil.java b/src/main/java/BookPick/mvp/global/util/JwtUtil.java new file mode 100644 index 0000000..761c1fa --- /dev/null +++ b/src/main/java/BookPick/mvp/global/util/JwtUtil.java @@ -0,0 +1,151 @@ +package BookPick.mvp.global.util; + +import BookPick.mvp.domain.auth.exception.InvalidTokenTypeException; +import BookPick.mvp.domain.auth.exception.JwtTokenExpiredException; +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.stream.Collectors; + + +@Slf4j +@Component +public class JwtUtil { + // 1. 키발급 + + private final SecretKey accessKey; + private final SecretKey refreshKey; + private final long accessTtl; + private final long refreshTtl; + + // 생성자 주입 + public JwtUtil( + @Value("${jwt.access.secret}") String accessSecret, + @Value("${jwt.refresh.secret}") String refreshSecret, + @Value("${jwt.access.expiration}") long accessTtl, + @Value("${jwt.refresh.expiration}") long refreshTtl + ) { + this.accessKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(accessSecret)); + this.refreshKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(refreshSecret)); + this.accessTtl = accessTtl; + this.refreshTtl = refreshTtl; + } + + // 2. JWT 생성 + public String createAccessToken(Authentication auth) { + CustomUserDetails usr = (CustomUserDetails) auth.getPrincipal(); + + String authorities = auth.getAuthorities().stream() //getAuthorities -> List return + .map(a -> a.getAuthority()) // getAuthority() -> String return + .collect(Collectors.joining(",")); + + + String jwt = Jwts.builder() + .claim("userId", usr.getId()) + .claim("email", usr.getUsername()) + .claim("authorities", authorities) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) // expiration : 만료 + .signWith(accessKey) + .compact(); + return jwt; + } + + // ✅ 2-1. Refresh 토큰 생성 (여기 추가) + public String createRefreshToken(Authentication auth) { + CustomUserDetails usr = (CustomUserDetails) auth.getPrincipal(); + + // refresh 토큰에는 최소 정보만: subject/email + typ 정도만 권장 + return Jwts.builder() + .claim("userId", usr.getId()) // 여기 추가 + .claim("email", usr.getUsername()) + .claim("typ", "refresh") // (권장) 토큰 타입 명시 + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + this.refreshTtl)) + .signWith(refreshKey) + .compact(); + } + + + //3. JWT 오픈 + public Claims extractToken(String token) { + + try { + Claims claims = Jwts.parser() + .verifyWith(accessKey) + .build() + .parseSignedClaims(token) + .getPayload(); + return claims; + + + } catch (ExpiredJwtException e) { + throw new JwtTokenExpiredException(); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenTypeException(); + } + } + + // Access Token 파싱 + public Claims extractAccessToken(String token) { + return extractToken(token, accessKey); + } + + // Refresh Token 파싱 + public Claims extractRefreshToken(String token) { + return extractToken(token, refreshKey); + } + + // 공통 파싱 로직 + public static Claims extractToken(String token, SecretKey key) { + try { + Claims claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims; + + } catch (ExpiredJwtException e) { + // 토큰 만료 예외를 커스텀 예외로 던짐 + throw new JwtTokenExpiredException(); + } catch (JwtException | IllegalArgumentException e) { + // 잘못된 토큰 예외를 커스텀 예외로 + System.out.println("JWT Parsing Error: " + e.getClass().getSimpleName() + " - " + e.getMessage()); + e.printStackTrace(); + + throw new InvalidTokenTypeException(); + } + + } + + // 토큰 유효성 검증 (Access / Refresh 구분) + public boolean validateToken(String token, boolean isAccessToken) { + try { + if (isAccessToken) { + extractAccessToken(token); + } else { + extractRefreshToken(token); + } + return true; // 예외가 안 나면 유효 + } catch (JwtTokenExpiredException e) { + System.out.println("토큰 만료: " + e.getMessage()); + return false; + } catch (InvalidTokenTypeException | JwtException e) { + System.out.println("유효하지 않은 토큰: " + e.getMessage()); + return false; + } + } +} + + + diff --git a/src/main/java/BookPick/mvp/security/config/JwtConfig.java b/src/main/java/BookPick/mvp/security/config/JwtConfig.java new file mode 100644 index 0000000..e2176a8 --- /dev/null +++ b/src/main/java/BookPick/mvp/security/config/JwtConfig.java @@ -0,0 +1,39 @@ +package BookPick.mvp.security.config; + +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import javax.crypto.SecretKey; + +@Configuration +@Getter +public class JwtConfig { + + @Value("${jwt.access.secret}") + private String accessSecret; + @Value("${jwt.access.expiration}") + private long accessSecretExp; + + @Value("${jwt.refresh.secret}") + private String refreshSecret; + @Value("${jwt.refresh.expiration}") + private long refreshSecretExp; + + + SecretKey getSecretKey(String secret) { + return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + } + + public SecretKey getAccessSecretKey() { + return getSecretKey(this.accessSecret); + } + + public SecretKey getRefreshSecretKey() { + return getSecretKey(this.refreshSecret); + } + + +} diff --git a/src/main/java/BookPick/mvp/security/config/SecurityConfig.java b/src/main/java/BookPick/mvp/security/config/SecurityConfig.java new file mode 100644 index 0000000..35dc6ed --- /dev/null +++ b/src/main/java/BookPick/mvp/security/config/SecurityConfig.java @@ -0,0 +1,108 @@ +package BookPick.mvp.security.config; + +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.config.JwtFilter; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + + private final JwtFilter jwtFilter; + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + + + // 로그인이 필요없는 것들 + // 1. auth + .requestMatchers("/api/v1/auth/signup", "/api/v1/auth/login", "/api/v1/auth/logout").permitAll() + // 2. author + // 3. book + // 4. comment + // 5. curation + // 6. ReadingPreference + // 7. user + .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll() + .requestMatchers("/error").permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(eh -> eh + .authenticationEntryPoint((req, res, ex) -> { + res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + res.setContentType("application/json; charset=UTF-8"); + + ApiResponse response = ApiResponse.customError( + HttpStatus.UNAUTHORIZED, + "로그인이 필요합니다.", + null + ); + + ObjectMapper objectMapper = new ObjectMapper(); + res.getWriter().write(objectMapper.writeValueAsString(response)); + }) + ) + + .logout(logout -> logout + .logoutUrl("/api/v1/logout") + .clearAuthentication(true) + .invalidateHttpSession(true) + .logoutSuccessHandler((req, res, auth) -> { + res.setStatus(org.springframework.http.HttpStatus.OK.value()); // 200 OK + })) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(List.of( + "http://localhost:5173", + "https://bookpick-front.vercel.app", + "https://bookpick-front-dev.vercel.app" + + )); + + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With", "Accept", "Origin")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/src/main/java/BookPick/mvp/security/handler/CustomAuthenticationEntryPoint.java b/src/main/java/BookPick/mvp/security/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..92e3b25 --- /dev/null +++ b/src/main/java/BookPick/mvp/security/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,37 @@ +package BookPick.mvp.security.handler; + +import BookPick.mvp.global.api.ApiResponse; +import BookPick.mvp.global.api.ErrorCode.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override +public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + + // request에 담긴 예외 코드 확인 + ErrorCode errorCode = (ErrorCode) request.getAttribute("exception"); + if (errorCode == null) { + errorCode = ErrorCode.UNAUTHORIZED; // 기본값 + } + + ApiResponse errorResponse = ApiResponse.error(errorCode); + + response.setStatus(errorCode.getStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); +} + +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..9cbb25a --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,35 @@ +spring: + datasource: + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: ${SPRING_JPA_DDL_AUTO} + properties: + hibernate: + show_sql: true + default_batch_fetch_size: 100 # N+1 방지 (한 번에 100개씩 배치 조회) + +logging: + level: + org.springframework.web: DEBUG + BookPick: DEBUG + +jwt: + access: + secret: ${JWT_ACCESS_SECRET} + expiration: ${JWT_ACCESS_EXPIRATION} + refresh: + secret: ${JWT_REFRESH_SECRET} + expiration: ${JWT_REFRESH_EXPIRATION} + +api: + kakao: + key: ${KAKAO_API_KEY} + gemini: + api: + key: ${GEMINI_API_KEY} + url: ${GEMINI_API_URL} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..9cbb25a --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,35 @@ +spring: + datasource: + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: ${SPRING_JPA_DDL_AUTO} + properties: + hibernate: + show_sql: true + default_batch_fetch_size: 100 # N+1 방지 (한 번에 100개씩 배치 조회) + +logging: + level: + org.springframework.web: DEBUG + BookPick: DEBUG + +jwt: + access: + secret: ${JWT_ACCESS_SECRET} + expiration: ${JWT_ACCESS_EXPIRATION} + refresh: + secret: ${JWT_REFRESH_SECRET} + expiration: ${JWT_REFRESH_EXPIRATION} + +api: + kakao: + key: ${KAKAO_API_KEY} + gemini: + api: + key: ${GEMINI_API_KEY} + url: ${GEMINI_API_URL} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..9cbb25a --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,35 @@ +spring: + datasource: + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: ${SPRING_JPA_DDL_AUTO} + properties: + hibernate: + show_sql: true + default_batch_fetch_size: 100 # N+1 방지 (한 번에 100개씩 배치 조회) + +logging: + level: + org.springframework.web: DEBUG + BookPick: DEBUG + +jwt: + access: + secret: ${JWT_ACCESS_SECRET} + expiration: ${JWT_ACCESS_EXPIRATION} + refresh: + secret: ${JWT_REFRESH_SECRET} + expiration: ${JWT_REFRESH_EXPIRATION} + +api: + kakao: + key: ${KAKAO_API_KEY} + gemini: + api: + key: ${GEMINI_API_KEY} + url: ${GEMINI_API_URL} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..442293c --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,15 @@ +spring: + application: + name: BookPick + +server: + port: 8081 + error: + include-message: always + include-binding-errors: always + include-stacktrace: always + include-exception: true + +logging: + level: + root: INFO diff --git a/src/test/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceServiceTest.java b/src/test/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceServiceTest.java new file mode 100644 index 0000000..d7ceba5 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceServiceTest.java @@ -0,0 +1,370 @@ +package BookPick.mvp.domain.ReadingPreference.service; + +import BookPick.mvp.domain.ReadingPreference.Exception.fail.AlreadyRegisteredReadingPreferenceException; +import BookPick.mvp.domain.ReadingPreference.Exception.fail.UserReadingPreferenceNotExisted; +import BookPick.mvp.domain.ReadingPreference.dto.ETC.Delete.ReadingPreferenceDeleteRes; +import BookPick.mvp.domain.ReadingPreference.dto.ReadingPreferenceReq; +import BookPick.mvp.domain.ReadingPreference.dto.ReadingPreferenceRes; +import BookPick.mvp.domain.ReadingPreference.entity.ReadingPreference; +import BookPick.mvp.domain.ReadingPreference.repository.ReadingPreferenceRepository; +import BookPick.mvp.domain.author.dto.preference.AuthorDto; +import BookPick.mvp.domain.author.entity.Author; +import BookPick.mvp.domain.author.service.AuthorSaveService; +import BookPick.mvp.domain.book.dto.preference.BookDto; +import BookPick.mvp.domain.book.entity.Book; +import BookPick.mvp.domain.book.repository.BookRepository; +import BookPick.mvp.domain.book.service.BookSaveService; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("독서취향 서비스 테스트") +class ReadingPreferenceServiceTest { + + @InjectMocks + private ReadingPreferenceService readingPreferenceService; + + @Mock + private ReadingPreferenceRepository readingPreferenceRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private BookSaveService bookSaveService; + + @Mock + private AuthorSaveService authorSaveService; + + @Mock + private BookRepository bookRepository; + + @Mock + private ReadingPreferenceValidCheckService readingPreferenceValidCheckService; + + @Test + @DisplayName("정상 독서취향 등록 성공") + void addReadingPreference_success() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .password("password") + .build(); + + BookDto bookDto = new BookDto("Test Book", "Test Author", "image.jpg", "isbn123"); + AuthorDto authorDto = new AuthorDto("Test Author"); + + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(bookDto), + Set.of(authorDto), + List.of("차분한"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + Book mockBook = Book.builder() + .isbn("isbn123") + .title("Test Book") + .build(); + + Author mockAuthor = Author.builder() + .name("Test Author") + .build(); + + ReadingPreference mockPreference = ReadingPreference.builder() + .user(mockUser) + .mbti("INTJ") + .favoriteBooks(Set.of(mockBook)) + .favoriteAuthors(Set.of(mockAuthor)) + .moods(List.of("차분한")) + .readingHabits(List.of("매일 읽기")) + .genres(List.of("소설")) + .keywords(List.of("성장")) + .readingStyles(List.of("몰입형")) + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(readingPreferenceRepository.existsByUserId(userId)).thenReturn(false); + when(bookSaveService.saveBookIfNotExistsDto(anySet())).thenReturn(Set.of(mockBook)); + when(authorSaveService.saveAuthorIfNotExistsDto(anySet())).thenReturn(Set.of(mockAuthor)); + when(readingPreferenceRepository.save(any(ReadingPreference.class))).thenReturn(mockPreference); + + // when + ReadingPreferenceRes result = readingPreferenceService.addReadingPreference(userId, req); + + // then + assertThat(result).isNotNull(); + assertThat(result.mbti()).isEqualTo("INTJ"); + verify(userRepository).findById(userId); + verify(readingPreferenceRepository).existsByUserId(userId); + verify(bookSaveService).saveBookIfNotExistsDto(anySet()); + verify(authorSaveService).saveAuthorIfNotExistsDto(anySet()); + verify(readingPreferenceRepository).save(any(ReadingPreference.class)); + } + + @Test + @DisplayName("독서취향 등록 실패 - 사용자 없음") + void addReadingPreference_fail_userNotFound() { + // given + Long userId = 1L; + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of("차분한"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> readingPreferenceService.addReadingPreference(userId, req)) + .isInstanceOf(UserNotFoundException.class); + + verify(userRepository).findById(userId); + verify(readingPreferenceRepository, never()).save(any()); + } + + @Test + @DisplayName("독서취향 등록 실패 - 이미 등록됨") + void addReadingPreference_fail_alreadyRegistered() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .password("password") + .build(); + + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of("차분한"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(readingPreferenceRepository.existsByUserId(userId)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> readingPreferenceService.addReadingPreference(userId, req)) + .isInstanceOf(AlreadyRegisteredReadingPreferenceException.class); + + verify(readingPreferenceRepository, never()).save(any()); + } + + @Test + @DisplayName("빈 독서취향 등록 성공") + void addClearReadingPreference_success() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .password("password") + .build(); + + ReadingPreference mockPreference = ReadingPreference.clearPreferences(mockUser); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(readingPreferenceRepository.existsByUserId(userId)).thenReturn(false); + when(readingPreferenceRepository.save(any(ReadingPreference.class))).thenReturn(mockPreference); + + // when + ReadingPreferenceRes result = readingPreferenceService.addClearReadingPreference(userId); + + // then + assertThat(result).isNotNull(); + verify(userRepository).findById(userId); + verify(readingPreferenceRepository).existsByUserId(userId); + verify(readingPreferenceRepository).save(any(ReadingPreference.class)); + } + + @Test + @DisplayName("독서취향 조회 성공") + void findReadingPreference_success() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .password("password") + .build(); + + ReadingPreference mockPreference = ReadingPreference.builder() + .user(mockUser) + .mbti("INTJ") + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(readingPreferenceRepository.findByUserId(userId)).thenReturn(Optional.of(mockPreference)); + + // when + ReadingPreferenceRes result = readingPreferenceService.findReadingPreference(userId); + + // then + assertThat(result).isNotNull(); + assertThat(result.mbti()).isEqualTo("INTJ"); + verify(userRepository).findById(userId); + verify(readingPreferenceRepository).findByUserId(userId); + } + + @Test + @DisplayName("독서취향 조회 실패 - 독서취향 없음") + void findReadingPreference_fail_notExisted() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .password("password") + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(readingPreferenceRepository.findByUserId(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> readingPreferenceService.findReadingPreference(userId)) + .isInstanceOf(UserReadingPreferenceNotExisted.class); + + verify(userRepository).findById(userId); + verify(readingPreferenceRepository).findByUserId(userId); + } + + @Test + @DisplayName("독서취향 수정 성공") + void modifyReadingPreference_success() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .password("password") + .build(); + + BookDto bookDto = new BookDto("New Book", "New Author", "new_image.jpg", "isbn456"); + AuthorDto authorDto = new AuthorDto("New Author"); + + ReadingPreferenceReq req = new ReadingPreferenceReq( + "ENFP", + Set.of(bookDto), + Set.of(authorDto), + List.of("설레는"), + List.of("주말에 읽기"), + List.of("에세이"), + List.of("힐링"), + List.of("건너뛰며") + ); + + Book mockBook = Book.builder() + .isbn("isbn456") + .title("New Book") + .build(); + + Author mockAuthor = Author.builder() + .name("New Author") + .build(); + + ReadingPreference mockPreference = ReadingPreference.builder() + .user(mockUser) + .mbti("INTJ") + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(readingPreferenceRepository.findByUserId(userId)).thenReturn(Optional.of(mockPreference)); + when(bookSaveService.saveBookIfNotExistsDto(anySet())).thenReturn(Set.of(mockBook)); + when(authorSaveService.saveAuthorIfNotExistsDto(anySet())).thenReturn(Set.of(mockAuthor)); + doNothing().when(readingPreferenceValidCheckService).validateReadingPreferenceReq(any()); + + // when + ReadingPreferenceRes result = readingPreferenceService.modifyReadingPreference(userId, req); + + // then + assertThat(result).isNotNull(); + verify(userRepository).findById(userId); + verify(readingPreferenceRepository).findByUserId(userId); + verify(bookSaveService).saveBookIfNotExistsDto(anySet()); + verify(authorSaveService).saveAuthorIfNotExistsDto(anySet()); + verify(readingPreferenceValidCheckService).validateReadingPreferenceReq(req); + } + + @Test + @DisplayName("독서취향 삭제 성공") + void removeReadingPreference_success() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .password("password") + .build(); + + ReadingPreference mockPreference = ReadingPreference.builder() + .user(mockUser) + .mbti("INTJ") + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(readingPreferenceRepository.findByUserId(userId)).thenReturn(Optional.of(mockPreference)); + doNothing().when(readingPreferenceRepository).delete(mockPreference); + + // when + ReadingPreferenceDeleteRes result = readingPreferenceService.removeReadingPreference(userId); + + // then + assertThat(result).isNotNull(); + assertThat(result.deletedAt()).isNotNull(); + verify(userRepository).findById(userId); + verify(readingPreferenceRepository).findByUserId(userId); + verify(readingPreferenceRepository).delete(mockPreference); + } + + @Test + @DisplayName("독서취향 삭제 실패 - 독서취향 없음") + void removeReadingPreference_fail_notExisted() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .password("password") + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(readingPreferenceRepository.findByUserId(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> readingPreferenceService.removeReadingPreference(userId)) + .isInstanceOf(UserReadingPreferenceNotExisted.class); + + verify(readingPreferenceRepository, never()).delete(any()); + } +} diff --git a/src/test/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceValidCheckServiceTest.java b/src/test/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceValidCheckServiceTest.java new file mode 100644 index 0000000..d3a6104 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/ReadingPreference/service/ReadingPreferenceValidCheckServiceTest.java @@ -0,0 +1,276 @@ +package BookPick.mvp.domain.ReadingPreference.service; + +import BookPick.mvp.domain.ReadingPreference.Exception.fail.WrongReadingPreferenceRequestException; +import BookPick.mvp.domain.ReadingPreference.dto.ReadingPreferenceReq; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith(MockitoExtension.class) +@DisplayName("독서취향 검증 서비스 테스트") +class ReadingPreferenceValidCheckServiceTest { + + @InjectMocks + private ReadingPreferenceValidCheckService validCheckService; + + @Test + @DisplayName("정상 독서취향 요청 검증 성공") + void validateReadingPreferenceReq_success() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of("차분한"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + // when & then + assertThatCode(() -> validCheckService.validateReadingPreferenceReq(req)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("MBTI null 또는 빈 문자열 - 검증 통과") + void validateReadingPreferenceReq_nullOrEmptyMbti_success() { + // given + ReadingPreferenceReq req1 = new ReadingPreferenceReq( + null, + Set.of(), + Set.of(), + List.of("차분한"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + ReadingPreferenceReq req2 = new ReadingPreferenceReq( + "", + Set.of(), + Set.of(), + List.of("차분한"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + // when & then + assertThatCode(() -> validCheckService.validateReadingPreferenceReq(req1)) + .doesNotThrowAnyException(); + assertThatCode(() -> validCheckService.validateReadingPreferenceReq(req2)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("잘못된 MBTI - 검증 실패") + void validateReadingPreferenceReq_invalidMbti_fail() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INVALID", + Set.of(), + Set.of(), + List.of("차분한"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + // when & then + assertThatThrownBy(() -> validCheckService.validateReadingPreferenceReq(req)) + .isInstanceOf(WrongReadingPreferenceRequestException.class); + } + + @Test + @DisplayName("잘못된 Mood - 검증 실패") + void validateReadingPreferenceReq_invalidMood_fail() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of("잘못된_무드"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + // when & then + assertThatThrownBy(() -> validCheckService.validateReadingPreferenceReq(req)) + .isInstanceOf(WrongReadingPreferenceRequestException.class); + } + + @Test + @DisplayName("잘못된 ReadingHabit - 검증 실패") + void validateReadingPreferenceReq_invalidReadingHabit_fail() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of("차분한"), + List.of("잘못된_습관"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + // when & then + assertThatThrownBy(() -> validCheckService.validateReadingPreferenceReq(req)) + .isInstanceOf(WrongReadingPreferenceRequestException.class); + } + + @Test + @DisplayName("잘못된 Genre - 검증 실패") + void validateReadingPreferenceReq_invalidGenre_fail() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of("차분한"), + List.of("매일 읽기"), + List.of("잘못된_장르"), + List.of("성장"), + List.of("몰입형") + ); + + // when & then + assertThatThrownBy(() -> validCheckService.validateReadingPreferenceReq(req)) + .isInstanceOf(WrongReadingPreferenceRequestException.class); + } + + @Test + @DisplayName("잘못된 Keyword - 검증 실패") + void validateReadingPreferenceReq_invalidKeyword_fail() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of("차분한"), + List.of("매일 읽기"), + List.of("소설"), + List.of("잘못된_키워드"), + List.of("몰입형") + ); + + // when & then + assertThatThrownBy(() -> validCheckService.validateReadingPreferenceReq(req)) + .isInstanceOf(WrongReadingPreferenceRequestException.class); + } + + @Test + @DisplayName("잘못된 ReadingStyle - 검증 실패") + void validateReadingPreferenceReq_invalidReadingStyle_fail() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of("차분한"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("잘못된_스타일") + ); + + // when & then + assertThatThrownBy(() -> validCheckService.validateReadingPreferenceReq(req)) + .isInstanceOf(WrongReadingPreferenceRequestException.class); + } + + @Test + @DisplayName("null 컬렉션 - 검증 통과") + void validateReadingPreferenceReq_nullCollections_success() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + null, + null, + null, + null, + null + ); + + // when & then + assertThatCode(() -> validCheckService.validateReadingPreferenceReq(req)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("빈 컬렉션 - 검증 통과") + void validateReadingPreferenceReq_emptyCollections_success() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + + // when & then + assertThatCode(() -> validCheckService.validateReadingPreferenceReq(req)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("여러 올바른 값 - 검증 통과") + void validateReadingPreferenceReq_multipleValidValues_success() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "ENFP", + Set.of(), + Set.of(), + List.of("차분한", "설레는"), + List.of("매일 읽기", "주말에 읽기"), + List.of("소설", "에세이"), + List.of("성장", "힐링"), + List.of("몰입형", "건너뛰며") + ); + + // when & then + assertThatCode(() -> validCheckService.validateReadingPreferenceReq(req)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("혼합된 올바른 값과 잘못된 값 - 검증 실패") + void validateReadingPreferenceReq_mixedValidAndInvalid_fail() { + // given + ReadingPreferenceReq req = new ReadingPreferenceReq( + "INTJ", + Set.of(), + Set.of(), + List.of("차분한", "잘못된_무드"), + List.of("매일 읽기"), + List.of("소설"), + List.of("성장"), + List.of("몰입형") + ); + + // when & then + assertThatThrownBy(() -> validCheckService.validateReadingPreferenceReq(req)) + .isInstanceOf(WrongReadingPreferenceRequestException.class); + } +} diff --git a/src/test/java/BookPick/mvp/domain/auth/controller/LoginControllerTest.java b/src/test/java/BookPick/mvp/domain/auth/controller/LoginControllerTest.java new file mode 100644 index 0000000..e941793 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/auth/controller/LoginControllerTest.java @@ -0,0 +1,269 @@ +package BookPick.mvp.domain.auth.controller; + +import BookPick.mvp.domain.auth.dto.LoginReq; +import BookPick.mvp.domain.auth.dto.LoginRes; +import BookPick.mvp.domain.auth.exception.InvalidLoginException; +import BookPick.mvp.domain.auth.service.LoginService; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.RefreshTokenCookieManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@ActiveProfiles("test") +@Transactional +@DisplayName("로그인 컨트롤러 테스트") +class LoginControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private LoginService loginService; + + @MockBean + private RefreshTokenCookieManager refreshTokenCookieManager; + + @Test + @DisplayName("정상 로그인 성공 - 첫 로그인") + void login_success_firstLogin() throws Exception { + // given + LoginReq req = new LoginReq("test@test.com", "password123"); + LoginRes loginRes = new LoginRes( + 1L, + "test@test.com", + "테스터", + "자기소개", + "profile.jpg", + true, + "access_token", + "refresh_token" + ); + + when(loginService.login(any(LoginReq.class), any(HttpServletRequest.class))) + .thenReturn(loginRes); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("LOGIN_SUCCESS")) + .andExpect(jsonPath("$.data.userId").value(1L)) + .andExpect(jsonPath("$.data.email").value("test@test.com")) + .andExpect(jsonPath("$.data.nickname").value("테스터")) + .andExpect(jsonPath("$.data.accessToken").value("access_token")) + .andExpect(jsonPath("$.data.isFirstLogin").value(true)) + .andExpect(jsonPath("$.data.refreshToken").doesNotExist()); // refresh token은 응답에 없어야 함 + + verify(loginService).login(any(LoginReq.class), any(HttpServletRequest.class)); + verify(refreshTokenCookieManager).addRefreshTokenCookie(any(HttpServletResponse.class), eq("refresh_token")); + } + + @Test + @DisplayName("정상 로그인 성공 - 재로그인") + void login_success_notFirstLogin() throws Exception { + // given + LoginReq req = new LoginReq("test@test.com", "password123"); + LoginRes loginRes = new LoginRes( + 1L, + "test@test.com", + "테스터", + "자기소개", + "profile.jpg", + false, + "access_token", + "refresh_token" + ); + + when(loginService.login(any(LoginReq.class), any(HttpServletRequest.class))) + .thenReturn(loginRes); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isFirstLogin").value(false)); + + verify(refreshTokenCookieManager).addRefreshTokenCookie(any(HttpServletResponse.class), eq("refresh_token")); + } + + @Test + @DisplayName("로그인 실패 - 잘못된 비밀번호") + void login_fail_wrongPassword() throws Exception { + // given + LoginReq req = new LoginReq("test@test.com", "wrongPassword"); + + when(loginService.login(any(LoginReq.class), any(HttpServletRequest.class))) + .thenThrow(new InvalidLoginException()); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isUnauthorized()); + + verify(loginService).login(any(LoginReq.class), any(HttpServletRequest.class)); + verify(refreshTokenCookieManager, never()).addRefreshTokenCookie(any(), anyString()); + } + + @Test + @DisplayName("로그인 실패 - 존재하지 않는 사용자") + void login_fail_userNotFound() throws Exception { + // given + LoginReq req = new LoginReq("notexist@test.com", "password123"); + + when(loginService.login(any(LoginReq.class), any(HttpServletRequest.class))) + .thenThrow(new InvalidLoginException()); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isUnauthorized()); + + verify(refreshTokenCookieManager, never()).addRefreshTokenCookie(any(), anyString()); + } + + @Test + @DisplayName("이메일 형식 검증 - 빈 이메일") + void login_fail_emptyEmail() throws Exception { + // given + LoginReq req = new LoginReq("", "password123"); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + + verify(loginService, never()).login(any(LoginReq.class), any(HttpServletRequest.class)); + } + + @Test + @DisplayName("이메일 형식 검증 - 잘못된 형식") + void login_fail_invalidEmailFormat() throws Exception { + // given + LoginReq req = new LoginReq("invalid-email", "password123"); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + + verify(loginService, never()).login(any(LoginReq.class), any(HttpServletRequest.class)); + } + + @Test + @DisplayName("비밀번호 검증 - 빈 비밀번호") + void login_fail_emptyPassword() throws Exception { + // given + LoginReq req = new LoginReq("test@test.com", ""); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + + verify(loginService, never()).login(any(LoginReq.class), any(HttpServletRequest.class)); + } + + @Test + @DisplayName("요청 바디 없음 - 예외 발생") + void login_fail_noRequestBody() throws Exception { + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + verify(loginService, never()).login(any(LoginReq.class), any(HttpServletRequest.class)); + } + + @Test + @DisplayName("리프레시 토큰 쿠키 설정 확인") + void login_refreshTokenCookie_set() throws Exception { + // given + LoginReq req = new LoginReq("test@test.com", "password123"); + LoginRes loginRes = new LoginRes( + 1L, + "test@test.com", + "테스터", + "자기소개", + "profile.jpg", + false, + "access_token", + "refresh_token_value" + ); + + when(loginService.login(any(LoginReq.class), any(HttpServletRequest.class))) + .thenReturn(loginRes); + + // when + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()); + + // then + verify(refreshTokenCookieManager).addRefreshTokenCookie( + any(HttpServletResponse.class), + eq("refresh_token_value") + ); + } + + @Test + @DisplayName("응답에서 리프레시 토큰 제거 확인") + void login_refreshTokenNotInResponse() throws Exception { + // given + LoginReq req = new LoginReq("test@test.com", "password123"); + LoginRes loginRes = new LoginRes( + 1L, + "test@test.com", + "테스터", + "자기소개", + "profile.jpg", + false, + "access_token", + "refresh_token" + ); + + when(loginService.login(any(LoginReq.class), any(HttpServletRequest.class))) + .thenReturn(loginRes); + + // when & then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").exists()) + .andExpect(jsonPath("$.data.refreshToken").doesNotExist()); + } +} diff --git a/src/test/java/BookPick/mvp/domain/auth/controller/LogoutControllerTest.java b/src/test/java/BookPick/mvp/domain/auth/controller/LogoutControllerTest.java new file mode 100644 index 0000000..9cc38e0 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/auth/controller/LogoutControllerTest.java @@ -0,0 +1,192 @@ +package BookPick.mvp.domain.auth.controller; + +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.auth.exception.NotAuthenticateUser; +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.auth.service.LogoutService; +import BookPick.mvp.domain.user.entity.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; + +@WebMvcTest(value = LogoutController.class, + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + JpaRepositoriesAutoConfiguration.class + }) +@DisplayName("로그아웃 컨트롤러 테스트") +class LogoutControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LogoutService logoutService; + + @MockBean + private BookPick.mvp.global.util.JwtUtil jwtUtil; + + @MockBean + private BookPick.mvp.global.config.JwtFilter jwtFilter; + + @Test + @DisplayName("정상 로그아웃 성공") + @WithMockUser + void logout_success() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .role(Roles.ROLE_USER) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + + doNothing().when(logoutService).logout(any(), any(HttpServletRequest.class), any(HttpServletResponse.class)); + + // when & then + mockMvc.perform(post("/api/v1/auth/logout") + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("LOGOUT_SUCCESS")) + .andExpect(jsonPath("$.data").doesNotExist()); + + verify(logoutService).logout(any(), any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + @Test + @DisplayName("미인증 사용자 로그아웃 시도 - 예외 발생") + void logout_fail_notAuthenticated() throws Exception { + // given + doThrow(new NotAuthenticateUser()) + .when(logoutService).logout(any(), any(HttpServletRequest.class), any(HttpServletResponse.class)); + + // when & then + mockMvc.perform(post("/api/v1/auth/logout")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("로그아웃 서비스 호출 확인") + @WithMockUser + void logout_serviceInvoked() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + + doNothing().when(logoutService).logout(any(), any(HttpServletRequest.class), any(HttpServletResponse.class)); + + // when + mockMvc.perform(post("/api/v1/auth/logout") + .with(user(userDetails))) + .andExpect(status().isOk()); + + // then + verify(logoutService, times(1)).logout(any(), any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + @Test + @DisplayName("로그아웃 응답 형식 확인") + @WithMockUser + void logout_responseFormat() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + + doNothing().when(logoutService).logout(any(), any(HttpServletRequest.class), any(HttpServletResponse.class)); + + // when & then + mockMvc.perform(post("/api/v1/auth/logout") + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").exists()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName("여러 사용자 연속 로그아웃") + @WithMockUser + void logout_multipleUsers() throws Exception { + // given + User user1 = User.builder() + .id(1L) + .email("user1@test.com") + .password("password") + .build(); + + User user2 = User.builder() + .id(2L) + .email("user2@test.com") + .password("password") + .build(); + + CustomUserDetails userDetails1 = new CustomUserDetails( + user1, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails1.setId(1L); + + CustomUserDetails userDetails2 = new CustomUserDetails( + user2, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails2.setId(2L); + + doNothing().when(logoutService).logout(any(), any(HttpServletRequest.class), any(HttpServletResponse.class)); + + // when & then + mockMvc.perform(post("/api/v1/auth/logout") + .with(user(userDetails1))) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/v1/auth/logout") + .with(user(userDetails2))) + .andExpect(status().isOk()); + + verify(logoutService, times(2)).logout(any(), any(HttpServletRequest.class), any(HttpServletResponse.class)); + } +} diff --git a/src/test/java/BookPick/mvp/domain/auth/controller/SignUpControllerTest.java b/src/test/java/BookPick/mvp/domain/auth/controller/SignUpControllerTest.java new file mode 100644 index 0000000..5f7a3ae --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/auth/controller/SignUpControllerTest.java @@ -0,0 +1,186 @@ +package BookPick.mvp.domain.auth.controller; + +import BookPick.mvp.domain.auth.dto.SignReq; +import BookPick.mvp.domain.auth.dto.SignRes; +import BookPick.mvp.domain.auth.exception.DuplicateEmailException; +import BookPick.mvp.domain.auth.service.SignUpService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; + +@WebMvcTest(value = SignUpController.class, + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + JpaRepositoriesAutoConfiguration.class + }) +@DisplayName("회원가입 컨트롤러 테스트") +class SignUpControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private SignUpService authService; + + @MockBean + private BookPick.mvp.global.util.JwtUtil jwtUtil; + + @MockBean + private BookPick.mvp.global.config.JwtFilter jwtFilter; + + @Test + @DisplayName("정상 회원가입 성공") + void signUp_success() throws Exception { + // given + SignReq req = new SignReq("test@test.com", "password123"); + SignRes res = new SignRes(1L); + + when(authService.signUp(any(SignReq.class))).thenReturn(res); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("REGISTER_SUCCESS")) + .andExpect(jsonPath("$.data.userId").value(1L)); + + verify(authService).signUp(any(SignReq.class)); + } + + @Test + @DisplayName("이메일 중복 - 예외 발생") + void signUp_fail_duplicateEmail() throws Exception { + // given + SignReq req = new SignReq("duplicate@test.com", "password123"); + + when(authService.signUp(any(SignReq.class))) + .thenThrow(new DuplicateEmailException()); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + + verify(authService).signUp(any(SignReq.class)); + } + + @Test + @DisplayName("이메일 형식 검증 - 빈 이메일") + void signUp_fail_emptyEmail() throws Exception { + // given + SignReq req = new SignReq("", "password123"); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + + verify(authService, never()).signUp(any(SignReq.class)); + } + + @Test + @DisplayName("이메일 형식 검증 - 잘못된 형식") + void signUp_fail_invalidEmailFormat() throws Exception { + // given + SignReq req = new SignReq("invalid-email", "password123"); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + + verify(authService, never()).signUp(any(SignReq.class)); + } + + @Test + @DisplayName("비밀번호 검증 - 빈 비밀번호") + void signUp_fail_emptyPassword() throws Exception { + // given + SignReq req = new SignReq("test@test.com", ""); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + + verify(authService, never()).signUp(any(SignReq.class)); + } + + @Test + @DisplayName("요청 바디 없음 - 예외 발생") + void signUp_fail_noRequestBody() throws Exception { + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + verify(authService, never()).signUp(any(SignReq.class)); + } + + @Test + @DisplayName("Content-Type 누락 - 예외 발생") + void signUp_fail_noContentType() throws Exception { + // given + SignReq req = new SignReq("test@test.com", "password123"); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isUnsupportedMediaType()); + + verify(authService, never()).signUp(any(SignReq.class)); + } + + @Test + @DisplayName("여러 사용자 연속 회원가입") + void signUp_multipleUsers_success() throws Exception { + // given + SignReq req1 = new SignReq("user1@test.com", "password1"); + SignReq req2 = new SignReq("user2@test.com", "password2"); + + SignRes res1 = new SignRes(1L); + SignRes res2 = new SignRes(2L); + + when(authService.signUp(any(SignReq.class))) + .thenReturn(res1) + .thenReturn(res2); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req1))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value(1L)); + + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req2))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value(2L)); + + verify(authService, times(2)).signUp(any(SignReq.class)); + } +} diff --git a/src/test/java/BookPick/mvp/domain/auth/controller/TokenRefreshControllerTest.java b/src/test/java/BookPick/mvp/domain/auth/controller/TokenRefreshControllerTest.java new file mode 100644 index 0000000..a4b69c1 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/auth/controller/TokenRefreshControllerTest.java @@ -0,0 +1,332 @@ +package BookPick.mvp.domain.auth.controller; + +import BookPick.mvp.domain.auth.dto.LoginRes; +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.auth.service.TokenRefreshService; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.RefreshTokenCookieManager; +import BookPick.mvp.domain.user.entity.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; + +@WebMvcTest(value = TokenRefreshController.class, + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + JpaRepositoriesAutoConfiguration.class + }) +@DisplayName("토큰 갱신 컨트롤러 테스트") +class TokenRefreshControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private RefreshTokenCookieManager refreshTokenCookieManager; + + @MockBean + private TokenRefreshService tokenRefreshService; + + @MockBean + private BookPick.mvp.global.util.JwtUtil jwtUtil; + + @MockBean + private BookPick.mvp.global.config.JwtFilter jwtFilter; + + @Test + @DisplayName("정상 토큰 갱신 성공") + @WithMockUser + void refreshToken_success() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .nickname("테스터") + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + userDetails.setNickname("테스터"); + + String oldRefreshToken = "old_refresh_token"; + LoginRes newTokens = new LoginRes( + 1L, + "test@test.com", + "테스터", + "자기소개", + "profile.jpg", + false, + "new_access_token", + "new_refresh_token" + ); + + when(refreshTokenCookieManager.getRefreshTokenFromCookie(any(HttpServletRequest.class))) + .thenReturn(oldRefreshToken); + when(tokenRefreshService.refreshTokens(any(CustomUserDetails.class), eq(oldRefreshToken))) + .thenReturn(newTokens); + + // when & then + mockMvc.perform(post("/api/v1/auth/token/refresh") + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("TOKEN_REFERSH_SUCCESS")) + .andExpect(jsonPath("$.data.userId").value(1L)) + .andExpect(jsonPath("$.data.accessToken").value("new_access_token")) + .andExpect(jsonPath("$.data.refreshToken").doesNotExist()); // refresh token은 응답에 없어야 함 + + verify(refreshTokenCookieManager).getRefreshTokenFromCookie(any(HttpServletRequest.class)); + verify(tokenRefreshService).refreshTokens(any(CustomUserDetails.class), eq(oldRefreshToken)); + verify(refreshTokenCookieManager).addRefreshTokenCookie(any(HttpServletResponse.class), eq("new_refresh_token")); + } + + @Test + @DisplayName("토큰 갱신 실패 - 리프레시 토큰 없음") + @WithMockUser + void refreshToken_fail_noRefreshToken() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + + when(refreshTokenCookieManager.getRefreshTokenFromCookie(any(HttpServletRequest.class))) + .thenReturn(null); + when(tokenRefreshService.refreshTokens(any(CustomUserDetails.class), isNull())) + .thenThrow(new RuntimeException("리프레시 토큰이 없습니다.")); + + // when & then + mockMvc.perform(post("/api/v1/auth/token/refresh") + .with(user(userDetails))) + .andExpect(status().is5xxServerError()); + + verify(refreshTokenCookieManager).getRefreshTokenFromCookie(any(HttpServletRequest.class)); + verify(refreshTokenCookieManager, never()).addRefreshTokenCookie(any(), anyString()); + } + + @Test + @DisplayName("토큰 갱신 실패 - 만료된 리프레시 토큰") + @WithMockUser + void refreshToken_fail_expiredToken() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + + String expiredToken = "expired_refresh_token"; + + when(refreshTokenCookieManager.getRefreshTokenFromCookie(any(HttpServletRequest.class))) + .thenReturn(expiredToken); + when(tokenRefreshService.refreshTokens(any(CustomUserDetails.class), eq(expiredToken))) + .thenThrow(new RuntimeException("리프레시 토큰이 만료되었거나 유효하지 않습니다.")); + + // when & then + mockMvc.perform(post("/api/v1/auth/token/refresh") + .with(user(userDetails))) + .andExpect(status().is5xxServerError()); + + verify(refreshTokenCookieManager, never()).addRefreshTokenCookie(any(), anyString()); + } + + @Test + @DisplayName("토큰 갱신 실패 - 블랙리스트 토큰") + @WithMockUser + void refreshToken_fail_blacklistedToken() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + + String blacklistedToken = "blacklisted_token"; + + when(refreshTokenCookieManager.getRefreshTokenFromCookie(any(HttpServletRequest.class))) + .thenReturn(blacklistedToken); + when(tokenRefreshService.refreshTokens(any(CustomUserDetails.class), eq(blacklistedToken))) + .thenThrow(new RuntimeException("유효하지 않은 리프레시 토큰입니다.")); + + // when & then + mockMvc.perform(post("/api/v1/auth/token/refresh") + .with(user(userDetails))) + .andExpect(status().is5xxServerError()); + + verify(refreshTokenCookieManager, never()).addRefreshTokenCookie(any(), anyString()); + } + + @Test + @DisplayName("미인증 사용자 토큰 갱신 시도") + void refreshToken_fail_notAuthenticated() throws Exception { + // when & then + mockMvc.perform(post("/api/v1/auth/token/refresh")) + .andExpect(status().isUnauthorized()); + + verify(refreshTokenCookieManager, never()).getRefreshTokenFromCookie(any()); + verify(tokenRefreshService, never()).refreshTokens(any(), anyString()); + } + + @Test + @DisplayName("새 리프레시 토큰 쿠키 설정 확인") + @WithMockUser + void refreshToken_newRefreshTokenCookieSet() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + + String oldToken = "old_token"; + String newRefreshToken = "brand_new_refresh_token"; + LoginRes newTokens = new LoginRes( + 1L, + "test@test.com", + "테스터", + "자기소개", + "profile.jpg", + false, + "new_access_token", + newRefreshToken + ); + + when(refreshTokenCookieManager.getRefreshTokenFromCookie(any(HttpServletRequest.class))) + .thenReturn(oldToken); + when(tokenRefreshService.refreshTokens(any(CustomUserDetails.class), eq(oldToken))) + .thenReturn(newTokens); + + // when + mockMvc.perform(post("/api/v1/auth/token/refresh") + .with(user(userDetails))) + .andExpect(status().isOk()); + + // then + verify(refreshTokenCookieManager).addRefreshTokenCookie( + any(HttpServletResponse.class), + eq(newRefreshToken) + ); + } + + @Test + @DisplayName("응답에서 리프레시 토큰 제거 확인") + @WithMockUser + void refreshToken_refreshTokenNotInResponse() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + + LoginRes newTokens = new LoginRes( + 1L, + "test@test.com", + "테스터", + "자기소개", + "profile.jpg", + false, + "new_access_token", + "new_refresh_token" + ); + + when(refreshTokenCookieManager.getRefreshTokenFromCookie(any(HttpServletRequest.class))) + .thenReturn("old_token"); + when(tokenRefreshService.refreshTokens(any(CustomUserDetails.class), anyString())) + .thenReturn(newTokens); + + // when & then + mockMvc.perform(post("/api/v1/auth/token/refresh") + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").exists()) + .andExpect(jsonPath("$.data.refreshToken").doesNotExist()); + } + + @Test + @DisplayName("토큰 갱신 서비스 호출 확인") + @WithMockUser + void refreshToken_serviceInvoked() throws Exception { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails userDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + userDetails.setId(1L); + + String refreshToken = "refresh_token"; + LoginRes newTokens = new LoginRes(1L, "test@test.com", "테스터", "자기소개", "profile.jpg", false, "access", "refresh"); + + when(refreshTokenCookieManager.getRefreshTokenFromCookie(any(HttpServletRequest.class))) + .thenReturn(refreshToken); + when(tokenRefreshService.refreshTokens(any(CustomUserDetails.class), eq(refreshToken))) + .thenReturn(newTokens); + + // when + mockMvc.perform(post("/api/v1/auth/token/refresh") + .with(user(userDetails))) + .andExpect(status().isOk()); + + // then + verify(tokenRefreshService, times(1)).refreshTokens(any(CustomUserDetails.class), eq(refreshToken)); + } +} diff --git a/src/test/java/BookPick/mvp/domain/auth/service/LoginServiceTest.java b/src/test/java/BookPick/mvp/domain/auth/service/LoginServiceTest.java new file mode 100644 index 0000000..7fb35bd --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/auth/service/LoginServiceTest.java @@ -0,0 +1,224 @@ +package BookPick.mvp.domain.auth.service; + +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.auth.dto.LoginReq; +import BookPick.mvp.domain.auth.dto.LoginRes; +import BookPick.mvp.domain.auth.exception.InvalidLoginException; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.JwtAuthManager; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Collections; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("로그인 서비스 테스트") +class LoginServiceTest { + + @InjectMocks + private LoginService loginService; + + @Mock + private JwtAuthManager jwtAuthManager; + + @Mock + private AuthenticationManagerBuilder authenticationManagerBuilder; + + @Mock + private UserRepository userRepository; + + @Mock + private HttpServletRequest httpServletRequest; + + @Mock + private AuthenticationManager authenticationManager; + + @Mock + private Authentication authentication; + + @Test + @DisplayName("정상 로그인 - 첫 로그인") + void login_success_firstLogin() { + // given + LoginReq req = new LoginReq("test@test.com", "password123"); + User mockUser = User.builder() + .id(1L) + .email(req.email()) + .password("encodedPassword") + .nickname("테스터") + .role(Roles.ROLE_USER) + .isFirstLogin(true) + .build(); + + CustomUserDetails mockUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + mockUserDetails.setId(mockUser.getId()); + mockUserDetails.setNickname(mockUser.getNickname()); + mockUserDetails.setFirstLogin(mockUser.isFirstLogin()); + + JwtAuthManager.TokenPair tokenPair = new JwtAuthManager.TokenPair( + "access_token", + "refresh_token" + ); + + when(authenticationManagerBuilder.getObject()).thenReturn(authenticationManager); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(mockUserDetails); + when(jwtAuthManager.createTokens(authentication)).thenReturn(tokenPair); + when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser)); + + // when + LoginRes result = loginService.login(req, httpServletRequest); + + // then + assertThat(result.userId()).isEqualTo(1L); + assertThat(result.accessToken()).isEqualTo("access_token"); + assertThat(result.refreshToken()).isEqualTo("refresh_token"); + assertThat(result.isFirstLogin()).isTrue(); + + verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(jwtAuthManager).createTokens(authentication); + verify(userRepository).findById(1L); + assertThat(mockUser.isFirstLogin()).isFalse(); // 첫 로그인 플래그 업데이트 확인 + } + + @Test + @DisplayName("정상 로그인 - 재로그인") + void login_success_notFirstLogin() { + // given + LoginReq req = new LoginReq("test@test.com", "password123"); + User mockUser = User.builder() + .id(1L) + .email(req.email()) + .password("encodedPassword") + .nickname("테스터") + .role(Roles.ROLE_USER) + .isFirstLogin(false) + .build(); + + CustomUserDetails mockUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + mockUserDetails.setId(mockUser.getId()); + mockUserDetails.setNickname(mockUser.getNickname()); + mockUserDetails.setFirstLogin(mockUser.isFirstLogin()); + + JwtAuthManager.TokenPair tokenPair = new JwtAuthManager.TokenPair( + "access_token", + "refresh_token" + ); + + when(authenticationManagerBuilder.getObject()).thenReturn(authenticationManager); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(mockUserDetails); + when(jwtAuthManager.createTokens(authentication)).thenReturn(tokenPair); + + // when + LoginRes result = loginService.login(req, httpServletRequest); + + // then + assertThat(result.isFirstLogin()).isFalse(); + verify(userRepository, never()).findById(anyLong()); // 첫 로그인이 아니면 업데이트 안함 + } + + @Test + @DisplayName("잘못된 비밀번호 - 예외 발생") + void login_fail_wrongPassword() { + // given + LoginReq req = new LoginReq("test@test.com", "wrongPassword"); + + when(authenticationManagerBuilder.getObject()).thenReturn(authenticationManager); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new BadCredentialsException("Bad credentials")); + + // when & then + assertThatThrownBy(() -> loginService.login(req, httpServletRequest)) + .isInstanceOf(InvalidLoginException.class); + + verify(jwtAuthManager, never()).createTokens(any()); + } + + @Test + @DisplayName("존재하지 않는 사용자 - 예외 발생") + void login_fail_userNotFound() { + // given + LoginReq req = new LoginReq("notexist@test.com", "password123"); + + when(authenticationManagerBuilder.getObject()).thenReturn(authenticationManager); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new UsernameNotFoundException("User not found")); + + // when & then + assertThatThrownBy(() -> loginService.login(req, httpServletRequest)) + .isInstanceOf(InvalidLoginException.class); + + verify(jwtAuthManager, never()).createTokens(any()); + } + + @Test + @DisplayName("JWT 토큰 생성 확인") + void login_tokenGeneration() { + // given + LoginReq req = new LoginReq("test@test.com", "password123"); + User mockUser = User.builder() + .id(1L) + .email(req.email()) + .password("encodedPassword") + .role(Roles.ROLE_USER) + .isFirstLogin(false) + .build(); + + CustomUserDetails mockUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + mockUserDetails.setId(mockUser.getId()); + mockUserDetails.setNickname(mockUser.getNickname()); + mockUserDetails.setFirstLogin(mockUser.isFirstLogin()); + + JwtAuthManager.TokenPair tokenPair = new JwtAuthManager.TokenPair( + "generated_access_token", + "generated_refresh_token" + ); + + when(authenticationManagerBuilder.getObject()).thenReturn(authenticationManager); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(mockUserDetails); + when(jwtAuthManager.createTokens(authentication)).thenReturn(tokenPair); + + // when + LoginRes result = loginService.login(req, httpServletRequest); + + // then + assertThat(result.accessToken()).isNotNull(); + assertThat(result.refreshToken()).isNotNull(); + assertThat(result.accessToken()).isEqualTo("generated_access_token"); + assertThat(result.refreshToken()).isEqualTo("generated_refresh_token"); + + verify(jwtAuthManager).createTokens(authentication); + } +} diff --git a/src/test/java/BookPick/mvp/domain/auth/service/LogoutServiceTest.java b/src/test/java/BookPick/mvp/domain/auth/service/LogoutServiceTest.java new file mode 100644 index 0000000..06b96ad --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/auth/service/LogoutServiceTest.java @@ -0,0 +1,308 @@ +package BookPick.mvp.domain.auth.service; + +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.auth.exception.JwtTokenExpiredException; +import BookPick.mvp.domain.auth.exception.NotAuthenticateUser; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.TokenBlacklistManager; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.global.util.JwtUtil; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.time.Instant; +import java.util.Collections; +import java.util.Date; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("로그아웃 서비스 테스트") +class LogoutServiceTest { + + @InjectMocks + private LogoutService logoutService; + + @Mock + private JwtUtil jwtUtil; + + @Mock + private TokenBlacklistManager tokenBlacklistManager; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Test + @DisplayName("정상 로그아웃 성공") + void logout_success() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .role(Roles.ROLE_USER) + .build(); + + CustomUserDetails currentUser = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + currentUser.setId(1L); + + String refreshToken = "valid_refresh_token"; + Cookie refreshCookie = new Cookie("refreshToken", refreshToken); + + Claims claims = mock(Claims.class); + when(claims.get("userId", Number.class)).thenReturn(1); + when(claims.getId()).thenReturn("jti-12345"); + when(claims.getExpiration()).thenReturn(new Date(System.currentTimeMillis() + 3600000)); // 1시간 후 + + when(request.getCookies()).thenReturn(new Cookie[]{refreshCookie}); + when(jwtUtil.extractRefreshToken(refreshToken)).thenReturn(claims); + + // when + logoutService.logout(currentUser, request, response); + + // then + verify(jwtUtil).extractRefreshToken(refreshToken); + verify(tokenBlacklistManager).add(eq("jti-12345"), any(Instant.class)); + verify(response).addCookie(argThat(cookie -> + cookie.getName().equals("refreshToken") && + cookie.getValue() == null && + cookie.getMaxAge() == 0 && + cookie.isHttpOnly() && + cookie.getSecure() + )); + } + + @Test + @DisplayName("미인증 사용자 로그아웃 시도 - 예외 발생") + void logout_fail_notAuthenticated() { + // given + CustomUserDetails currentUser = null; + + // when & then + assertThatThrownBy(() -> logoutService.logout(currentUser, request, response)) + .isInstanceOf(NotAuthenticateUser.class); + + verify(tokenBlacklistManager, never()).add(anyString(), any(Instant.class)); + verify(response, never()).addCookie(any(Cookie.class)); + } + + @Test + @DisplayName("쿠키가 없는 경우 - 정상 종료") + void logout_noCookies_success() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails currentUser = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + currentUser.setId(1L); + + when(request.getCookies()).thenReturn(null); + + // when + logoutService.logout(currentUser, request, response); + + // then + verify(jwtUtil, never()).extractRefreshToken(anyString()); + verify(tokenBlacklistManager, never()).add(anyString(), any(Instant.class)); + verify(response, never()).addCookie(any(Cookie.class)); + } + + @Test + @DisplayName("리프레시 토큰이 빈 값인 경우 - 정상 종료") + void logout_emptyRefreshToken_success() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails currentUser = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + currentUser.setId(1L); + + Cookie emptyCookie = new Cookie("refreshToken", ""); + + when(request.getCookies()).thenReturn(new Cookie[]{emptyCookie}); + + // when + logoutService.logout(currentUser, request, response); + + // then + verify(jwtUtil, never()).extractRefreshToken(anyString()); + verify(tokenBlacklistManager, never()).add(anyString(), any(Instant.class)); + verify(response, never()).addCookie(any(Cookie.class)); + } + + @Test + @DisplayName("만료된 토큰으로 로그아웃 시도 - 예외 발생") + void logout_fail_expiredToken() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails currentUser = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + currentUser.setId(1L); + + String expiredToken = "expired_refresh_token"; + Cookie refreshCookie = new Cookie("refreshToken", expiredToken); + + when(request.getCookies()).thenReturn(new Cookie[]{refreshCookie}); + when(jwtUtil.extractRefreshToken(expiredToken)) + .thenThrow(new RuntimeException("Token expired")); + + // when & then + assertThatThrownBy(() -> logoutService.logout(currentUser, request, response)) + .isInstanceOf(JwtTokenExpiredException.class); + + verify(tokenBlacklistManager, never()).add(anyString(), any(Instant.class)); + } + + @Test + @DisplayName("다른 쿠키들 사이에 리프레시 토큰이 있는 경우") + void logout_withMultipleCookies_success() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails currentUser = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + currentUser.setId(1L); + + String refreshToken = "valid_refresh_token"; + Cookie[] cookies = new Cookie[]{ + new Cookie("sessionId", "session123"), + new Cookie("refreshToken", refreshToken), + new Cookie("otherCookie", "value") + }; + + Claims claims = mock(Claims.class); + when(claims.get("userId", Number.class)).thenReturn(1); + when(claims.getId()).thenReturn("jti-12345"); + when(claims.getExpiration()).thenReturn(new Date(System.currentTimeMillis() + 3600000)); + + when(request.getCookies()).thenReturn(cookies); + when(jwtUtil.extractRefreshToken(refreshToken)).thenReturn(claims); + + // when + logoutService.logout(currentUser, request, response); + + // then + verify(jwtUtil).extractRefreshToken(refreshToken); + verify(tokenBlacklistManager).add(eq("jti-12345"), any(Instant.class)); + verify(response).addCookie(any(Cookie.class)); + } + + @Test + @DisplayName("토큰 블랙리스트 추가 확인 - TTL 설정") + void logout_blacklistTTL_correct() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails currentUser = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + currentUser.setId(1L); + + String refreshToken = "valid_refresh_token"; + Cookie refreshCookie = new Cookie("refreshToken", refreshToken); + + long expirationTime = System.currentTimeMillis() + 7200000; // 2시간 후 + Claims claims = mock(Claims.class); + when(claims.get("userId", Number.class)).thenReturn(1); + when(claims.getId()).thenReturn("jti-unique"); + when(claims.getExpiration()).thenReturn(new Date(expirationTime)); + + when(request.getCookies()).thenReturn(new Cookie[]{refreshCookie}); + when(jwtUtil.extractRefreshToken(refreshToken)).thenReturn(claims); + + // when + logoutService.logout(currentUser, request, response); + + // then + verify(tokenBlacklistManager).add( + eq("jti-unique"), + argThat(instant -> { + long expectedMillis = expirationTime - System.currentTimeMillis(); + long actualMillis = instant.toEpochMilli() - System.currentTimeMillis(); + // 1초 정도 오차 허용 + return Math.abs(actualMillis - expectedMillis) < 1000; + }) + ); + } + + @Test + @DisplayName("만료된 토큰 (음수 TTL) - 블랙리스트 추가하지 않음") + void logout_expiredToken_notAddedToBlacklist() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails currentUser = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + currentUser.setId(1L); + + String refreshToken = "almost_expired_token"; + Cookie refreshCookie = new Cookie("refreshToken", refreshToken); + + Claims claims = mock(Claims.class); + when(claims.get("userId", Number.class)).thenReturn(1); + when(claims.getId()).thenReturn("jti-expired"); + when(claims.getExpiration()).thenReturn(new Date(System.currentTimeMillis() - 1000)); // 이미 만료됨 + + when(request.getCookies()).thenReturn(new Cookie[]{refreshCookie}); + when(jwtUtil.extractRefreshToken(refreshToken)).thenReturn(claims); + + // when + logoutService.logout(currentUser, request, response); + + // then + verify(tokenBlacklistManager, never()).add(anyString(), any(Instant.class)); + verify(response).addCookie(any(Cookie.class)); // 쿠키는 여전히 삭제됨 + } +} diff --git a/src/test/java/BookPick/mvp/domain/auth/service/MyUserDetailsServiceTest.java b/src/test/java/BookPick/mvp/domain/auth/service/MyUserDetailsServiceTest.java new file mode 100644 index 0000000..b227c47 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/auth/service/MyUserDetailsServiceTest.java @@ -0,0 +1,256 @@ +package BookPick.mvp.domain.auth.service; + +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserDetailsService 테스트") +class MyUserDetailsServiceTest { + + @InjectMocks + private MyUserDetailsService myUserDetailsService; + + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("정상 사용자 조회 - 일반 유저") + void loadUserByUsername_success_normalUser() { + // given + String email = "test@test.com"; + User mockUser = User.builder() + .id(1L) + .email(email) + .password("encodedPassword") + .nickname("테스터") + .bio("자기소개") + .profileImageUrl("profile.jpg") + .role(Roles.ROLE_USER) + .isFirstLogin(true) + .build(); + + when(userRepository.findByEmail(email)).thenReturn(Optional.of(mockUser)); + + // when + UserDetails result = myUserDetailsService.loadUserByUsername(email); + + // then + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(CustomUserDetails.class); + assertThat(result.getUsername()).isEqualTo(email); + assertThat(result.getPassword()).isEqualTo("encodedPassword"); + + CustomUserDetails customResult = (CustomUserDetails) result; + assertThat(customResult.getId()).isEqualTo(1L); + assertThat(customResult.getNickname()).isEqualTo("테스터"); + assertThat(customResult.getBio()).isEqualTo("자기소개"); + assertThat(customResult.getProfileImageUrl()).isEqualTo("profile.jpg"); + assertThat(customResult.isFirstLogin()).isTrue(); + + verify(userRepository).findByEmail(email); + } + + @Test + @DisplayName("권한 부여 확인 - ROLE_USER") + void loadUserByUsername_roleUser_granted() { + // given + String email = "user@test.com"; + User mockUser = User.builder() + .id(1L) + .email(email) + .password("password") + .role(Roles.ROLE_USER) + .build(); + + when(userRepository.findByEmail(email)).thenReturn(Optional.of(mockUser)); + + // when + UserDetails result = myUserDetailsService.loadUserByUsername(email); + + // then + assertThat(result.getAuthorities()).hasSize(1); + assertThat(result.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactly("ROLE_USER"); + + verify(userRepository).findByEmail(email); + } + + @Test + @DisplayName("존재하지 않는 사용자 - 예외 발생") + void loadUserByUsername_fail_userNotFound() { + // given + String email = "notexist@test.com"; + + when(userRepository.findByEmail(email)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> myUserDetailsService.loadUserByUsername(email)) + .isInstanceOf(UserNotFoundException.class); + + verify(userRepository).findByEmail(email); + } + + @Test + @DisplayName("CustomUserDetails 필드 전체 검증") + void loadUserByUsername_allFieldsSet() { + // given + String email = "complete@test.com"; + User mockUser = User.builder() + .id(999L) + .email(email) + .password("hashedPassword123") + .nickname("완전한사용자") + .bio("완전한 자기소개입니다") + .profileImageUrl("https://example.com/profile.jpg") + .role(Roles.ROLE_USER) + .isFirstLogin(false) + .build(); + + when(userRepository.findByEmail(email)).thenReturn(Optional.of(mockUser)); + + // when + UserDetails result = myUserDetailsService.loadUserByUsername(email); + + // then + CustomUserDetails customResult = (CustomUserDetails) result; + assertThat(customResult.getId()).isEqualTo(999L); + assertThat(customResult.getUsername()).isEqualTo(email); + assertThat(customResult.getPassword()).isEqualTo("hashedPassword123"); + assertThat(customResult.getNickname()).isEqualTo("완전한사용자"); + assertThat(customResult.getBio()).isEqualTo("완전한 자기소개입니다"); + assertThat(customResult.getProfileImageUrl()).isEqualTo("https://example.com/profile.jpg"); + assertThat(customResult.isFirstLogin()).isFalse(); + assertThat(customResult.getAuthorities()).hasSize(1); + + verify(userRepository).findByEmail(email); + } + + @Test + @DisplayName("닉네임이 null인 경우") + void loadUserByUsername_nullNickname_success() { + // given + String email = "nonickname@test.com"; + User mockUser = User.builder() + .id(1L) + .email(email) + .password("password") + .nickname(null) + .role(Roles.ROLE_USER) + .build(); + + when(userRepository.findByEmail(email)).thenReturn(Optional.of(mockUser)); + + // when + UserDetails result = myUserDetailsService.loadUserByUsername(email); + + // then + CustomUserDetails customResult = (CustomUserDetails) result; + assertThat(customResult.getNickname()).isNull(); + + verify(userRepository).findByEmail(email); + } + + @Test + @DisplayName("여러 번 호출해도 정상 동작") + void loadUserByUsername_multipleCalls_success() { + // given + String email = "multi@test.com"; + User mockUser = User.builder() + .id(1L) + .email(email) + .password("password") + .role(Roles.ROLE_USER) + .build(); + + when(userRepository.findByEmail(email)).thenReturn(Optional.of(mockUser)); + + // when + UserDetails result1 = myUserDetailsService.loadUserByUsername(email); + UserDetails result2 = myUserDetailsService.loadUserByUsername(email); + + // then + assertThat(result1).isNotNull(); + assertThat(result2).isNotNull(); + assertThat(result1.getUsername()).isEqualTo(result2.getUsername()); + + verify(userRepository, times(2)).findByEmail(email); + } + + @Test + @DisplayName("프로필 이미지 URL이 없는 경우") + void loadUserByUsername_noProfileImage_success() { + // given + String email = "noimage@test.com"; + User mockUser = User.builder() + .id(1L) + .email(email) + .password("password") + .nickname("이미지없음") + .profileImageUrl(null) + .role(Roles.ROLE_USER) + .build(); + + when(userRepository.findByEmail(email)).thenReturn(Optional.of(mockUser)); + + // when + UserDetails result = myUserDetailsService.loadUserByUsername(email); + + // then + CustomUserDetails customResult = (CustomUserDetails) result; + assertThat(customResult.getProfileImageUrl()).isNull(); + assertThat(customResult.getNickname()).isEqualTo("이미지없음"); + + verify(userRepository).findByEmail(email); + } + + @Test + @DisplayName("첫 로그인 플래그 확인") + void loadUserByUsername_firstLoginFlag() { + // given + String email1 = "first@test.com"; + String email2 = "second@test.com"; + + User firstLoginUser = User.builder() + .id(1L) + .email(email1) + .password("password") + .role(Roles.ROLE_USER) + .isFirstLogin(true) + .build(); + + User returningUser = User.builder() + .id(2L) + .email(email2) + .password("password") + .role(Roles.ROLE_USER) + .isFirstLogin(false) + .build(); + + when(userRepository.findByEmail(email1)).thenReturn(Optional.of(firstLoginUser)); + when(userRepository.findByEmail(email2)).thenReturn(Optional.of(returningUser)); + + // when + CustomUserDetails firstResult = (CustomUserDetails) myUserDetailsService.loadUserByUsername(email1); + CustomUserDetails secondResult = (CustomUserDetails) myUserDetailsService.loadUserByUsername(email2); + + // then + assertThat(firstResult.isFirstLogin()).isTrue(); + assertThat(secondResult.isFirstLogin()).isFalse(); + } +} diff --git a/src/test/java/BookPick/mvp/domain/auth/service/SignUpServiceTest.java b/src/test/java/BookPick/mvp/domain/auth/service/SignUpServiceTest.java new file mode 100644 index 0000000..ae2ffaf --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/auth/service/SignUpServiceTest.java @@ -0,0 +1,143 @@ +package BookPick.mvp.domain.auth.service; + +import BookPick.mvp.domain.ReadingPreference.service.ReadingPreferenceService; +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.auth.dto.SignReq; +import BookPick.mvp.domain.auth.dto.SignRes; +import BookPick.mvp.domain.auth.exception.DuplicateEmailException; +import BookPick.mvp.domain.auth.util.Manager.signup.SignUpManager; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("회원가입 서비스 테스트") +class SignUpServiceTest { + + @InjectMocks + private SignUpService signUpService; + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private SignUpManager signUpManager; + + @Mock + private ReadingPreferenceService readingPreferenceService; + + @Test + @DisplayName("정상 회원가입 - 일반 유저") + void signUp_success_normalUser() { + // given + SignReq req = new SignReq("test@test.com", "password123"); + User mockUser = User.builder() + .id(1L) + .email(req.email()) + .password("encodedPassword") + .role(Roles.ROLE_USER) + .build(); + + when(userRepository.existsByEmail(req.email())).thenReturn(false); + when(signUpManager.isAdmin(req.email())).thenReturn(false); + when(passwordEncoder.encode(req.password())).thenReturn("encodedPassword"); + when(userRepository.save(any(User.class))).thenReturn(mockUser); + + // when + SignRes result = signUpService.signUp(req); + + // then + assertThat(result.userId()).isEqualTo(1L); + verify(userRepository).existsByEmail(req.email()); + verify(passwordEncoder).encode(req.password()); + verify(userRepository).save(any(User.class)); + verify(readingPreferenceService).addClearReadingPreference(1L); + } + + @Test + @DisplayName("정상 회원가입 - 관리자") + void signUp_success_adminUser() { + // given + SignReq req = new SignReq("admin@bookpick.com", "password123"); + User mockUser = User.builder() + .id(1L) + .email(req.email()) + .password("encodedPassword") + .role(Roles.ROLE_ADMIN) + .build(); + + when(userRepository.existsByEmail(req.email())).thenReturn(false); + when(signUpManager.isAdmin(req.email())).thenReturn(true); + when(passwordEncoder.encode(req.password())).thenReturn("encodedPassword"); + when(userRepository.save(any(User.class))).thenReturn(mockUser); + + // when + SignRes result = signUpService.signUp(req); + + // then + assertThat(result.userId()).isEqualTo(1L); + verify(signUpManager).isAdmin(req.email()); + verify(userRepository).save(argThat(user -> + user.getRole() == Roles.ROLE_ADMIN + )); + } + + @Test + @DisplayName("이메일 중복 - 예외 발생") + void signUp_fail_duplicateEmail() { + // given + SignReq req = new SignReq("duplicate@test.com", "password123"); + when(userRepository.existsByEmail(req.email())).thenReturn(true); + + // when & then + assertThatThrownBy(() -> signUpService.signUp(req)) + .isInstanceOf(DuplicateEmailException.class); + + verify(userRepository).existsByEmail(req.email()); + verify(userRepository, never()).save(any(User.class)); + verify(readingPreferenceService, never()).addClearReadingPreference(anyLong()); + } + + @Test + @DisplayName("비밀번호 암호화 확인") + void signUp_passwordEncoded() { + // given + SignReq req = new SignReq("test@test.com", "plainPassword"); + String encodedPassword = "encrypted_password_hash"; + + User mockUser = User.builder() + .id(1L) + .email(req.email()) + .password(encodedPassword) + .build(); + + when(userRepository.existsByEmail(req.email())).thenReturn(false); + when(signUpManager.isAdmin(req.email())).thenReturn(false); + when(passwordEncoder.encode(req.password())).thenReturn(encodedPassword); + when(userRepository.save(any(User.class))).thenReturn(mockUser); + + // when + signUpService.signUp(req); + + // then + verify(passwordEncoder).encode("plainPassword"); + verify(userRepository).save(argThat(user -> + user.getPassword().equals(encodedPassword) && + !user.getPassword().equals("plainPassword") + )); + } +} diff --git a/src/test/java/BookPick/mvp/domain/auth/service/TokenRefreshServiceTest.java b/src/test/java/BookPick/mvp/domain/auth/service/TokenRefreshServiceTest.java new file mode 100644 index 0000000..e2ab08c --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/auth/service/TokenRefreshServiceTest.java @@ -0,0 +1,347 @@ +package BookPick.mvp.domain.auth.service; + +import BookPick.mvp.domain.auth.Roles; +import BookPick.mvp.domain.auth.dto.LoginRes; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.JwtAuthManager; +import BookPick.mvp.domain.auth.util.Manager.login.jwt.TokenBlacklistManager; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.global.util.JwtUtil; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("토큰 갱신 서비스 테스트") +class TokenRefreshServiceTest { + + @InjectMocks + private TokenRefreshService tokenRefreshService; + + @Mock + private JwtAuthManager jwtAuthManager; + + @Mock + private TokenBlacklistManager tokenBlacklistManager; + + @Mock + private JwtUtil jwtUtil; + + @Test + @DisplayName("정상 토큰 갱신 성공") + void refreshTokens_success() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .nickname("테스터") + .role(Roles.ROLE_USER) + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + customUserDetails.setId(1L); + customUserDetails.setNickname("테스터"); + + String refreshToken = "valid_refresh_token"; + + Claims claims = mock(Claims.class); + when(claims.get("userId", Double.class)).thenReturn(1.0); // Double로 저장 + + JwtAuthManager.TokenPair newTokenPair = new JwtAuthManager.TokenPair( + "new_access_token", + "new_refresh_token" + ); + + when(tokenBlacklistManager.isBlacklisted(refreshToken)).thenReturn(false); + when(jwtUtil.validateToken(refreshToken, false)).thenReturn(true); + when(jwtUtil.extractRefreshToken(refreshToken)).thenReturn(claims); + when(jwtAuthManager.createTokens(any())).thenReturn(newTokenPair); + + // when + LoginRes result = tokenRefreshService.refreshTokens(customUserDetails, refreshToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(1L); + assertThat(result.accessToken()).isEqualTo("new_access_token"); + assertThat(result.refreshToken()).isEqualTo("new_refresh_token"); + + verify(tokenBlacklistManager).isBlacklisted(refreshToken); + verify(jwtUtil).validateToken(refreshToken, false); + verify(jwtUtil).extractRefreshToken(refreshToken); + verify(jwtAuthManager).createTokens(any()); + } + + @Test + @DisplayName("토큰이 null인 경우 - 예외 발생") + void refreshTokens_fail_nullToken() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + customUserDetails.setId(1L); + + // when & then + assertThatThrownBy(() -> tokenRefreshService.refreshTokens(customUserDetails, null)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("리프레시 토큰이 없습니다"); + + verify(tokenBlacklistManager, never()).isBlacklisted(anyString()); + verify(jwtAuthManager, never()).createTokens(any()); + } + + @Test + @DisplayName("토큰이 빈 문자열인 경우 - 예외 발생") + void refreshTokens_fail_blankToken() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + customUserDetails.setId(1L); + + // when & then + assertThatThrownBy(() -> tokenRefreshService.refreshTokens(customUserDetails, " ")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("리프레시 토큰이 없습니다"); + + verify(tokenBlacklistManager, never()).isBlacklisted(anyString()); + } + + @Test + @DisplayName("블랙리스트에 등록된 토큰 - 예외 발생") + void refreshTokens_fail_blacklistedToken() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + customUserDetails.setId(1L); + + String blacklistedToken = "blacklisted_token"; + + when(tokenBlacklistManager.isBlacklisted(blacklistedToken)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> tokenRefreshService.refreshTokens(customUserDetails, blacklistedToken)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("유효하지 않은 리프레시 토큰입니다"); + + verify(tokenBlacklistManager).isBlacklisted(blacklistedToken); + verify(jwtUtil, never()).validateToken(anyString(), anyBoolean()); + verify(jwtAuthManager, never()).createTokens(any()); + } + + @Test + @DisplayName("만료된 토큰 - 예외 발생") + void refreshTokens_fail_expiredToken() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + customUserDetails.setId(1L); + + String expiredToken = "expired_token"; + + when(tokenBlacklistManager.isBlacklisted(expiredToken)).thenReturn(false); + when(jwtUtil.validateToken(expiredToken, false)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> tokenRefreshService.refreshTokens(customUserDetails, expiredToken)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("리프레시 토큰이 만료되었거나 유효하지 않습니다"); + + verify(jwtUtil).validateToken(expiredToken, false); + verify(jwtUtil, never()).extractRefreshToken(anyString()); + verify(jwtAuthManager, never()).createTokens(any()); + } + + @Test + @DisplayName("토큰의 사용자 정보와 현재 사용자 불일치 - 예외 발생") + void refreshTokens_fail_userMismatch() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + customUserDetails.setId(1L); + + String refreshToken = "valid_token_but_different_user"; + + Claims claims = mock(Claims.class); + when(claims.get("userId", Double.class)).thenReturn(2.0); // 다른 사용자 ID + + when(tokenBlacklistManager.isBlacklisted(refreshToken)).thenReturn(false); + when(jwtUtil.validateToken(refreshToken, false)).thenReturn(true); + when(jwtUtil.extractRefreshToken(refreshToken)).thenReturn(claims); + + // when & then + assertThatThrownBy(() -> tokenRefreshService.refreshTokens(customUserDetails, refreshToken)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("토큰 사용자 정보와 일치하지 않습니다"); + + verify(jwtUtil).extractRefreshToken(refreshToken); + verify(jwtAuthManager, never()).createTokens(any()); + } + + @Test + @DisplayName("토큰 갱신 후 새로운 토큰 페어 반환 확인") + void refreshTokens_newTokensReturned() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .nickname("테스터") + .role(Roles.ROLE_USER) + .isFirstLogin(false) + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + customUserDetails.setId(1L); + customUserDetails.setNickname("테스터"); + customUserDetails.setFirstLogin(false); + + String oldRefreshToken = "old_refresh_token"; + + Claims claims = mock(Claims.class); + when(claims.get("userId", Double.class)).thenReturn(1.0); + + JwtAuthManager.TokenPair newTokenPair = new JwtAuthManager.TokenPair( + "brand_new_access_token", + "brand_new_refresh_token" + ); + + when(tokenBlacklistManager.isBlacklisted(oldRefreshToken)).thenReturn(false); + when(jwtUtil.validateToken(oldRefreshToken, false)).thenReturn(true); + when(jwtUtil.extractRefreshToken(oldRefreshToken)).thenReturn(claims); + when(jwtAuthManager.createTokens(any())).thenReturn(newTokenPair); + + // when + LoginRes result = tokenRefreshService.refreshTokens(customUserDetails, oldRefreshToken); + + // then + assertThat(result.accessToken()).isEqualTo("brand_new_access_token"); + assertThat(result.refreshToken()).isEqualTo("brand_new_refresh_token"); + assertThat(result.accessToken()).isNotEqualTo(oldRefreshToken); + assertThat(result.nickname()).isEqualTo("테스터"); + assertThat(result.isFirstLogin()).isFalse(); + } + + @Test + @DisplayName("검증 순서 확인 - 블랙리스트 먼저, 유효성 두번째") + void refreshTokens_validationOrder() { + // given + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("password") + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + customUserDetails.setId(1L); + + String refreshToken = "test_token"; + + when(tokenBlacklistManager.isBlacklisted(refreshToken)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> tokenRefreshService.refreshTokens(customUserDetails, refreshToken)) + .isInstanceOf(RuntimeException.class); + + // 블랙리스트 체크는 실행되었지만, 유효성 검사는 실행되지 않아야 함 + verify(tokenBlacklistManager).isBlacklisted(refreshToken); + verify(jwtUtil, never()).validateToken(anyString(), anyBoolean()); + } + + @Test + @DisplayName("Integer 타입 userId도 정상 처리") + void refreshTokens_integerUserId_success() { + // given + User mockUser = User.builder() + .id(5L) + .email("test@test.com") + .password("password") + .nickname("테스터") + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails( + mockUser, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + customUserDetails.setId(5L); + customUserDetails.setNickname("테스터"); + + String refreshToken = "valid_token"; + + Claims claims = mock(Claims.class); + when(claims.get("userId", Double.class)).thenReturn(5.0); // Double로 저장 + + JwtAuthManager.TokenPair newTokenPair = new JwtAuthManager.TokenPair( + "access", "refresh" + ); + + when(tokenBlacklistManager.isBlacklisted(refreshToken)).thenReturn(false); + when(jwtUtil.validateToken(refreshToken, false)).thenReturn(true); + when(jwtUtil.extractRefreshToken(refreshToken)).thenReturn(claims); + when(jwtAuthManager.createTokens(any())).thenReturn(newTokenPair); + + // when + LoginRes result = tokenRefreshService.refreshTokens(customUserDetails, refreshToken); + + // then + assertThat(result.userId()).isEqualTo(5L); + } +} diff --git a/src/test/java/BookPick/mvp/domain/comment/dto/read/CommentDetailResTest.java b/src/test/java/BookPick/mvp/domain/comment/dto/read/CommentDetailResTest.java new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/BookPick/mvp/domain/comment/dto/read/ReceivedCommentsDTOTest.java b/src/test/java/BookPick/mvp/domain/comment/dto/read/ReceivedCommentsDTOTest.java new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/BookPick/mvp/domain/comment/dto/update/CommentUpdateResTest.java b/src/test/java/BookPick/mvp/domain/comment/dto/update/CommentUpdateResTest.java new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/BookPick/mvp/domain/comment/service/CommentServiceTest.java b/src/test/java/BookPick/mvp/domain/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..ce5b45e --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/comment/service/CommentServiceTest.java @@ -0,0 +1,265 @@ +package BookPick.mvp.domain.comment.service; + +import BookPick.mvp.domain.comment.dto.create.CommentCreateReq; +import BookPick.mvp.domain.comment.dto.create.CommentCreateRes; +import BookPick.mvp.domain.comment.dto.delete.CommentDeleteRes; +import BookPick.mvp.domain.comment.entity.Comment; +import BookPick.mvp.domain.comment.exception.CommentNotFoundException; +import BookPick.mvp.domain.comment.repository.CommentRepository; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("댓글 서비스 테스트") +class CommentServiceTest { + + @InjectMocks + private CommentService commentService; + + @Mock + private UserRepository userRepository; + + @Mock + private CurationRepository curationRepository; + + @Mock + private CommentRepository commentRepository; + + @Test + @DisplayName("댓글 생성 성공 - 일반 댓글") + void createComment_success() { + // given + Long userId = 1L; + Long curationId = 1L; + CommentCreateReq req = new CommentCreateReq(null, "좋은 큐레이션이네요!"); + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .commentCount(0) + .build(); + + Comment mockComment = Comment.builder() + .id(1L) + .user(mockUser) + .curation(mockCuration) + .content(req.content()) + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(commentRepository.save(any(Comment.class))).thenReturn(mockComment); + + // when + CommentCreateRes result = commentService.createComment(userId, curationId, req); + + // then + assertThat(result.commentId()).isEqualTo(1L); + assertThat(mockCuration.getCommentCount()).isEqualTo(1); + + verify(userRepository).findById(userId); + verify(curationRepository).findByIdWithLock(curationId); + verify(commentRepository).save(any(Comment.class)); + } + + @Test + @DisplayName("대댓글 생성 성공") + void createReply_success() { + // given + Long userId = 1L; + Long curationId = 1L; + Long parentCommentId = 10L; + CommentCreateReq req = new CommentCreateReq(parentCommentId, "답글입니다!"); + + User mockUser = User.builder().id(userId).build(); + Curation mockCuration = Curation.builder().id(curationId).commentCount(1).build(); + Comment parentComment = Comment.builder().id(parentCommentId).build(); + Comment mockReply = Comment.builder() + .id(2L) + .user(mockUser) + .curation(mockCuration) + .parent(parentComment) + .content(req.content()) + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(commentRepository.findById(parentCommentId)).thenReturn(Optional.of(parentComment)); + when(commentRepository.save(any(Comment.class))).thenReturn(mockReply); + + // when + CommentCreateRes result = commentService.createComment(userId, curationId, req); + + // then + assertThat(result.commentId()).isEqualTo(2L); + assertThat(mockCuration.getCommentCount()).isEqualTo(2); + + verify(commentRepository).findById(parentCommentId); + verify(commentRepository).save(argThat(comment -> + comment.getParent() != null && + comment.getParent().getId().equals(parentCommentId) + )); + } + + @Test + @DisplayName("댓글 삭제 성공") + void deleteComment_success() { + // given + Long curationId = 1L; + Long commentId = 1L; + + Curation mockCuration = Curation.builder() + .id(curationId) + .commentCount(5) + .build(); + + Comment mockComment = Comment.builder() + .id(commentId) + .content("댓글 내용") + .build(); + + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(mockComment)); + + // when + CommentDeleteRes result = commentService.deleteComment(curationId, commentId); + + // then + assertThat(result.commentId()).isEqualTo(commentId); + assertThat(mockCuration.getCommentCount()).isEqualTo(4); + + verify(curationRepository).findByIdWithLock(curationId); + verify(commentRepository).delete(mockComment); + } + + @Test + @DisplayName("존재하지 않는 유저 - 예외 발생") + void createComment_userNotFound() { + // given + Long userId = 999L; + Long curationId = 1L; + CommentCreateReq req = new CommentCreateReq(null, "댓글"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.createComment(userId, curationId, req)) + .isInstanceOf(UserNotFoundException.class); + + verify(userRepository).findById(userId); + verify(commentRepository, never()).save(any()); + } + + @Test + @DisplayName("존재하지 않는 큐레이션 - 예외 발생") + void createComment_curationNotFound() { + // given + Long userId = 1L; + Long curationId = 999L; + CommentCreateReq req = new CommentCreateReq(null, "댓글"); + + User mockUser = User.builder().id(userId).build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.createComment(userId, curationId, req)) + .isInstanceOf(CurationNotFoundException.class); + + verify(commentRepository, never()).save(any()); + } + + @Test + @DisplayName("존재하지 않는 부모 댓글 - 예외 발생") + void createReply_parentCommentNotFound() { + // given + Long userId = 1L; + Long curationId = 1L; + Long invalidParentId = 999L; + CommentCreateReq req = new CommentCreateReq(invalidParentId, "답글"); + + User mockUser = User.builder().id(userId).build(); + Curation mockCuration = Curation.builder().id(curationId).build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(commentRepository.findById(invalidParentId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.createComment(userId, curationId, req)) + .isInstanceOf(CommentNotFoundException.class); + + verify(commentRepository, never()).save(any()); + } + + @Test + @DisplayName("비관적 락 사용 확인 - 데드락 방지") + void createComment_usesPessimisticLock() { + // given + Long userId = 1L; + Long curationId = 1L; + CommentCreateReq req = new CommentCreateReq(null, "댓글"); + + User mockUser = User.builder().id(userId).build(); + Curation mockCuration = Curation.builder().id(curationId).commentCount(0).build(); + Comment mockComment = Comment.builder().id(1L).build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(commentRepository.save(any(Comment.class))).thenReturn(mockComment); + + // when + commentService.createComment(userId, curationId, req); + + // then + verify(curationRepository).findByIdWithLock(curationId); // 비관적 락 사용 + verify(curationRepository, never()).findById(curationId); // 일반 findById 사용 안함 + } + + @Test + @DisplayName("댓글 삭제 시 카운트가 0 이하로 내려가지 않음") + void deleteComment_countNotNegative() { + // given + Long curationId = 1L; + Long commentId = 1L; + + Curation mockCuration = Curation.builder() + .id(curationId) + .commentCount(0) + .build(); + + Comment mockComment = Comment.builder().id(commentId).build(); + + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(mockComment)); + + // when + commentService.deleteComment(curationId, commentId); + + // then + assertThat(mockCuration.getCommentCount()).isEqualTo(0); // 음수 안됨 + + verify(commentRepository).delete(mockComment); + } +} diff --git a/src/test/java/BookPick/mvp/domain/comment/service/PagenationServiceTest.java b/src/test/java/BookPick/mvp/domain/comment/service/PagenationServiceTest.java new file mode 100644 index 0000000..529caa0 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/comment/service/PagenationServiceTest.java @@ -0,0 +1,46 @@ +package BookPick.mvp.domain.comment.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +@DisplayName("페이지네이션 서비스 테스트") +class PagenationServiceTest { + + @InjectMocks + private PagenationService pagenationService; + + @Test + @DisplayName("음수 페이지 번호를 0으로 변환") + void changeMinusPageToZeroPage_minusPage() { + // given + int minusPage = -1; + + // when + int result = pagenationService.changeMinusPageToZeroPage(minusPage); + + // then + assertThat(result).isZero(); + } + + @Test + @DisplayName("0 또는 양수 페이지 번호는 그대로 유지") + void changeMinusPageToZeroPage_notMinusPage() { + // given + int zeroPage = 0; + int positivePage = 10; + + // when + int zeroResult = pagenationService.changeMinusPageToZeroPage(zeroPage); + int positiveResult = pagenationService.changeMinusPageToZeroPage(positivePage); + + // then + assertThat(zeroResult).isEqualTo(zeroPage); + assertThat(positiveResult).isEqualTo(positivePage); + } +} diff --git a/src/test/java/BookPick/mvp/domain/comment/service/ReceivedCommentsServiceTest.java b/src/test/java/BookPick/mvp/domain/comment/service/ReceivedCommentsServiceTest.java new file mode 100644 index 0000000..2190b15 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/comment/service/ReceivedCommentsServiceTest.java @@ -0,0 +1,67 @@ +package BookPick.mvp.domain.comment.service; + +import BookPick.mvp.domain.comment.dto.read.ReceivedCommentsDTO; +import BookPick.mvp.domain.comment.entity.Comment; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.comment.repository.CommentRepository; +import BookPick.mvp.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("받은 댓글 서비스 테스트") +class ReceivedCommentsServiceTest { + + @InjectMocks + private ReceivedCommentsService receivedCommentsService; + + @Mock + private CommentRepository commentRepository; + + @Test + @DisplayName("사용자가 받은 최신 댓글 3개 조회") + void receivedCommentsRead_success() { + // given + Long userId = 1L; + User user = User.builder().id(userId).build(); + Curation curation = Curation.builder().id(1L).build(); // Mock Curation + Comment comment1 = Comment.builder().id(1L).content("Comment 1").user(user).curation(curation).build(); + Comment comment2 = Comment.builder().id(2L).content("Comment 2").user(user).curation(curation).build(); + Comment comment3 = Comment.builder().id(3L).content("Comment 3").user(user).curation(curation).build(); + List comments = List.of(comment1, comment2, comment3); + + when(commentRepository.findLatestCommentsByUserId(userId, PageRequest.of(0, 3))).thenReturn(comments); + + // when + ReceivedCommentsDTO result = receivedCommentsService.receivedCommentsRead(userId); + + // then + assertThat(result.comments()).hasSize(3); + assertThat(result.comments().get(0).content()).isEqualTo("Comment 1"); + } + + @Test + @DisplayName("받은 댓글이 없을 경우 빈 리스트 반환") + void receivedCommentsRead_noComments() { + // given + Long userId = 1L; + when(commentRepository.findLatestCommentsByUserId(userId, PageRequest.of(0, 3))).thenReturn(Collections.emptyList()); + + // when + ReceivedCommentsDTO result = receivedCommentsService.receivedCommentsRead(userId); + + // then + assertThat(result.comments()).isEmpty(); + } +} diff --git a/src/test/java/BookPick/mvp/domain/curation/enums/common/SortTypeTest.java b/src/test/java/BookPick/mvp/domain/curation/enums/common/SortTypeTest.java new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/BookPick/mvp/domain/curation/service/CurationCreateServiceTest.java b/src/test/java/BookPick/mvp/domain/curation/service/CurationCreateServiceTest.java new file mode 100644 index 0000000..d7a466b --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/curation/service/CurationCreateServiceTest.java @@ -0,0 +1,198 @@ +package BookPick.mvp.domain.curation.service; + +import BookPick.mvp.domain.curation.dto.base.CurationReq; +import BookPick.mvp.domain.curation.dto.base.create.CurationCreateRes; +import BookPick.mvp.domain.curation.dto.base.create.CurationCreateResult; +import BookPick.mvp.domain.curation.dto.base.create.ETC.BookDto; +import BookPick.mvp.domain.curation.dto.base.create.ETC.RecommendDto; +import BookPick.mvp.domain.curation.dto.base.create.ETC.ThumbnailDto; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.service.base.create.CurationCreateService; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("큐레이션 생성 서비스 테스트") +class CurationCreateServiceTest { + + @InjectMocks + private CurationCreateService curationCreateService; + + @Mock + private CurationRepository curationRepository; + + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("큐레이션 발행 성공") + void publishCuration_success() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + CurationReq req = new CurationReq( + "테스트 큐레이션", + new ThumbnailDto("thumbnail.jpg", "#FFFFFF"), + new BookDto("책 제목", "작가", "1234567890", "book.jpg"), + "이 책은 정말 좋습니다.", + new RecommendDto( + List.of("감동적인"), + List.of("소설"), + List.of("사랑"), + List.of("감성적인") + ), + false // isDrafted = false (발행) + ); + + Curation mockCuration = Curation.builder() + .id(1L) + .user(mockUser) + .title(req.title()) + .isDrafted(false) + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationRepository.save(any(Curation.class))).thenReturn(mockCuration); + + // when + CurationCreateResult result = curationCreateService.saveCuration(userId, req); + + // then + assertThat(result.curationCreateRes().id()).isEqualTo(1L); + assertThat(result.successCode()).isEqualTo(SuccessCode.CURATION_PUBLISH_SUCCESS); + + verify(userRepository).findById(userId); + verify(curationRepository).save(argThat(c -> !c.getIsDrafted())); + } + + @Test + @DisplayName("큐레이션 임시저장 성공") + void draftCuration_success() { + // given + Long userId = 1L; + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + CurationReq req = new CurationReq( + "임시저장 큐레이션", + new ThumbnailDto("thumbnail.jpg", "#FFFFFF"), + new BookDto("책 제목", "작가", "1234567890", "book.jpg"), + "리뷰 작성 중...", + new RecommendDto( + List.of("감동적인"), + List.of("소설"), + List.of("사랑"), + List.of("감성적인") + ), + true // isDrafted = true (임시저장) + ); + + Curation mockCuration = Curation.builder() + .id(2L) + .user(mockUser) + .title(req.title()) + .isDrafted(true) + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationRepository.save(any(Curation.class))).thenReturn(mockCuration); + + // when + CurationCreateResult result = curationCreateService.saveCuration(userId, req); + + // then + assertThat(result.curationCreateRes().id()).isEqualTo(2L); + assertThat(result.successCode()).isEqualTo(SuccessCode.CURATION_DRAFT_SUCCESS); + + verify(userRepository).findById(userId); + verify(curationRepository).save(argThat(c -> c.getIsDrafted())); + } + + @Test + @DisplayName("존재하지 않는 유저 - 예외 발생") + void createCuration_userNotFound() { + // given + Long userId = 999L; + CurationReq req = new CurationReq( + "테스트", + new ThumbnailDto("thumb.jpg", "#FFF"), + new BookDto("책", "작가", "123", "book.jpg"), + "리뷰", + new RecommendDto(List.of(), List.of(), List.of(), List.of()), + false + ); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> curationCreateService.saveCuration(userId, req)) + .isInstanceOf(UserNotFoundException.class); + + verify(userRepository).findById(userId); + verify(curationRepository, never()).save(any(Curation.class)); + } + + @Test + @DisplayName("발행/임시저장 구분 확인") + void createCuration_publishVsDraft() { + // given + Long userId = 1L; + User mockUser = User.builder().id(userId).build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationRepository.save(any(Curation.class))).thenAnswer(invocation -> { + Curation c = invocation.getArgument(0); + return Curation.builder() + .id(1L) + .user(mockUser) + .isDrafted(c.getIsDrafted()) + .build(); + }); + + // when - 발행 + CurationReq publishReq = new CurationReq( + "발행", new ThumbnailDto("t.jpg", "#FFF"), + new BookDto("책", "작가", "123", "b.jpg"), "리뷰", + new RecommendDto(List.of(), List.of(), List.of(), List.of()), + false + ); + CurationCreateResult publishResult = curationCreateService.saveCuration(userId, publishReq); + + // when - 임시저장 + CurationReq draftReq = new CurationReq( + "임시", new ThumbnailDto("t.jpg", "#FFF"), + new BookDto("책", "작가", "123", "b.jpg"), "리뷰", + new RecommendDto(List.of(), List.of(), List.of(), List.of()), + true + ); + CurationCreateResult draftResult = curationCreateService.saveCuration(userId, draftReq); + + // then + assertThat(publishResult.successCode()).isEqualTo(SuccessCode.CURATION_PUBLISH_SUCCESS); + assertThat(draftResult.successCode()).isEqualTo(SuccessCode.CURATION_DRAFT_SUCCESS); + + verify(curationRepository, times(2)).save(any(Curation.class)); + } +} diff --git a/src/test/java/BookPick/mvp/domain/curation/service/CurationDeleteServiceTest.java b/src/test/java/BookPick/mvp/domain/curation/service/CurationDeleteServiceTest.java new file mode 100644 index 0000000..858cf21 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/curation/service/CurationDeleteServiceTest.java @@ -0,0 +1,355 @@ +package BookPick.mvp.domain.curation.service; + +import BookPick.mvp.domain.curation.dto.base.delete.CurationDeleteRes; +import BookPick.mvp.domain.curation.dto.base.delete.CurationListDeleteReq; +import BookPick.mvp.domain.curation.dto.base.delete.CurationListDeleteRes; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.exception.common.CurationAccessDeniedException; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.curation.service.base.delete.CurationDeleteService; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.domain.user.service.subscribe.CurationSubscribeService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("큐레이션 삭제 서비스 테스트") +class CurationDeleteServiceTest { + + @InjectMocks + private CurationDeleteService curationDeleteService; + + @Mock + private CurationRepository curationRepository; + + @Mock + private CurationLikeRepository curationLikeRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private CurationSubscribeService curationSubscribeService; + + @Test + @DisplayName("큐레이션 단건 삭제 성공") + void removeCuration_success() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(mockUser) + .title("삭제할 큐레이션") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + when(curationRepository.findById(curationId)).thenReturn(Optional.of(mockCuration)); + doNothing().when(curationRepository).delete(mockCuration); + + // when + CurationDeleteRes result = curationDeleteService.removeCuration(userId, curationId); + + // then + assertThat(result).isNotNull(); + assertThat(result.curationIds()).isEqualTo(curationId); + assertThat(result.deletedAt()).isNotNull(); + + verify(curationRepository).findById(curationId); + verify(curationRepository).delete(mockCuration); + } + + @Test + @DisplayName("큐레이션 단건 삭제 - 다른 사용자가 삭제 시도 시 예외 발생") + void removeCuration_notOwner_fail() { + // given + Long ownerId = 1L; + Long attackerId = 2L; + Long curationId = 1L; + + User owner = User.builder() + .id(ownerId) + .email("owner@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(owner) + .title("큐레이션") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + when(curationRepository.findById(curationId)).thenReturn(Optional.of(mockCuration)); + + // when & then + assertThatThrownBy(() -> curationDeleteService.removeCuration(attackerId, curationId)) + .isInstanceOf(CurationAccessDeniedException.class); + + verify(curationRepository).findById(curationId); + verify(curationRepository, never()).delete(any(Curation.class)); + } + + @Test + @DisplayName("큐레이션 단건 삭제 - 존재하지 않는 큐레이션") + void removeCuration_notFound_fail() { + // given + Long userId = 1L; + Long curationId = 999L; + + when(curationRepository.findById(curationId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> curationDeleteService.removeCuration(userId, curationId)) + .isInstanceOf(CurationNotFoundException.class); + + verify(curationRepository).findById(curationId); + verify(curationRepository, never()).delete(any(Curation.class)); + } + + @Test + @DisplayName("큐레이션 복수 삭제 성공") + void removeCurations_success() { + // given + Long userId = 1L; + List curationIds = List.of(1L, 2L, 3L); + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + Curation curation1 = Curation.builder() + .id(1L) + .user(mockUser) + .title("큐레이션 1") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + Curation curation2 = Curation.builder() + .id(2L) + .user(mockUser) + .title("큐레이션 2") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + Curation curation3 = Curation.builder() + .id(3L) + .user(mockUser) + .title("큐레이션 3") + .isDrafted(true) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + List curations = List.of(curation1, curation2, curation3); + + CurationListDeleteReq req = new CurationListDeleteReq(curationIds); + + when(curationRepository.findByIdIn(curationIds)).thenReturn(curations); + doNothing().when(curationRepository).delete(any(Curation.class)); + + // when + CurationListDeleteRes result = curationDeleteService.removeCurations(userId, req); + + // then + assertThat(result).isNotNull(); + assertThat(result.ids()).hasSize(3); + assertThat(result.ids()).containsExactlyInAnyOrder(1L, 2L, 3L); + assertThat(result.deletedAt()).isNotNull(); + + verify(curationRepository).findByIdIn(curationIds); + verify(curationRepository, times(3)).delete(any(Curation.class)); + } + + @Test + @DisplayName("큐레이션 복수 삭제 - 일부 큐레이션이 존재하지 않음") + void removeCurations_partialNotFound_fail() { + // given + Long userId = 1L; + List requestedIds = List.of(1L, 2L, 3L); + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + // 2개만 존재 (3번은 없음) + Curation curation1 = Curation.builder() + .id(1L) + .user(mockUser) + .title("큐레이션 1") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + Curation curation2 = Curation.builder() + .id(2L) + .user(mockUser) + .title("큐레이션 2") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + List foundCurations = List.of(curation1, curation2); + + CurationListDeleteReq req = new CurationListDeleteReq(requestedIds); + + when(curationRepository.findByIdIn(requestedIds)).thenReturn(foundCurations); + + // when & then + assertThatThrownBy(() -> curationDeleteService.removeCurations(userId, req)) + .isInstanceOf(CurationNotFoundException.class); + + verify(curationRepository).findByIdIn(requestedIds); + verify(curationRepository, never()).delete(any(Curation.class)); + } + + @Test + @DisplayName("큐레이션 복수 삭제 - 다른 사용자의 큐레이션 포함") + void removeCurations_containsOthersPost_fail() { + // given + Long userId = 1L; + Long otherUserId = 2L; + List curationIds = List.of(1L, 2L); + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + User otherUser = User.builder() + .id(otherUserId) + .email("other@test.com") + .build(); + + Curation myCuration = Curation.builder() + .id(1L) + .user(mockUser) + .title("내 큐레이션") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + Curation otherCuration = Curation.builder() + .id(2L) + .user(otherUser) + .title("다른 사람 큐레이션") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + List curations = List.of(myCuration, otherCuration); + + CurationListDeleteReq req = new CurationListDeleteReq(curationIds); + + when(curationRepository.findByIdIn(curationIds)).thenReturn(curations); + + // when & then + assertThatThrownBy(() -> curationDeleteService.removeCurations(userId, req)) + .isInstanceOf(CurationAccessDeniedException.class); + + verify(curationRepository).findByIdIn(curationIds); + // 첫 번째 큐레이션은 삭제되지만, 두 번째에서 예외 발생하므로 1번만 호출됨 + verify(curationRepository).delete(myCuration); + verify(curationRepository, times(1)).delete(any(Curation.class)); + } + + @Test + @DisplayName("큐레이션 복수 삭제 - 빈 리스트") + void removeCurations_emptyList_success() { + // given + Long userId = 1L; + List emptyIds = List.of(); + + CurationListDeleteReq req = new CurationListDeleteReq(emptyIds); + + when(curationRepository.findByIdIn(emptyIds)).thenReturn(List.of()); + + // when + CurationListDeleteRes result = curationDeleteService.removeCurations(userId, req); + + // then + assertThat(result).isNotNull(); + assertThat(result.ids()).isEmpty(); + + verify(curationRepository).findByIdIn(emptyIds); + verify(curationRepository, never()).delete(any(Curation.class)); + } +} diff --git a/src/test/java/BookPick/mvp/domain/curation/service/CurationLikeServiceTest.java b/src/test/java/BookPick/mvp/domain/curation/service/CurationLikeServiceTest.java new file mode 100644 index 0000000..2d36ce6 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/curation/service/CurationLikeServiceTest.java @@ -0,0 +1,209 @@ +package BookPick.mvp.domain.curation.service; + +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.entity.CurationLike; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.curation.service.like.CurationLikeService; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("큐레이션 좋아요 서비스 테스트") +class CurationLikeServiceTest { + + @InjectMocks + private CurationLikeService curationLikeService; + + @Mock + private CurationRepository curationRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private CurationLikeRepository curationLikeRepository; + + @Test + @DisplayName("좋아요 추가 성공") + void likeCuration_success() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .likeCount(0) + .build(); + + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationLikeRepository.findByUserIdAndCurationId(userId, curationId)) + .thenReturn(Optional.empty()); + when(curationLikeRepository.save(any(CurationLike.class))) + .thenReturn(CurationLike.builder().build()); + + // when + boolean result = curationLikeService.CurationLikeOrUnlike(userId, curationId); + + // then + assertThat(result).isTrue(); + assertThat(mockCuration.getLikeCount()).isEqualTo(1); + + verify(curationRepository).findByIdWithLock(curationId); + verify(curationLikeRepository).save(any(CurationLike.class)); + verify(curationLikeRepository, never()).delete(any()); + } + + @Test + @DisplayName("좋아요 취소 성공") + void unlikeCuration_success() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .likeCount(5) + .build(); + + CurationLike existingLike = CurationLike.builder() + .user(mockUser) + .curation(mockCuration) + .build(); + + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationLikeRepository.findByUserIdAndCurationId(userId, curationId)) + .thenReturn(Optional.of(existingLike)); + + // when + boolean result = curationLikeService.CurationLikeOrUnlike(userId, curationId); + + // then + assertThat(result).isFalse(); + assertThat(mockCuration.getLikeCount()).isEqualTo(4); + + verify(curationRepository).findByIdWithLock(curationId); + verify(curationLikeRepository).delete(existingLike); + verify(curationLikeRepository, never()).save(any()); + } + + @Test + @DisplayName("존재하지 않는 큐레이션 - 예외 발생") + void likeCuration_curationNotFound() { + // given + Long userId = 1L; + Long curationId = 999L; + + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> curationLikeService.CurationLikeOrUnlike(userId, curationId)) + .isInstanceOf(CurationNotFoundException.class); + + verify(curationRepository).findByIdWithLock(curationId); + verify(curationLikeRepository, never()).save(any()); + verify(curationLikeRepository, never()).delete(any()); + } + + @Test + @DisplayName("존재하지 않는 유저 - 예외 발생") + void likeCuration_userNotFound() { + // given + Long userId = 999L; + Long curationId = 1L; + + Curation mockCuration = Curation.builder() + .id(curationId) + .build(); + + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> curationLikeService.CurationLikeOrUnlike(userId, curationId)) + .isInstanceOf(UserNotFoundException.class); + + verify(curationLikeRepository, never()).save(any()); + verify(curationLikeRepository, never()).delete(any()); + } + + @Test + @DisplayName("좋아요 카운트가 0일 때 취소해도 음수 안됨") + void unlikeCuration_countNotNegative() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder().id(userId).build(); + Curation mockCuration = Curation.builder() + .id(curationId) + .likeCount(0) // 이미 0 + .build(); + + CurationLike existingLike = CurationLike.builder() + .user(mockUser) + .curation(mockCuration) + .build(); + + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationLikeRepository.findByUserIdAndCurationId(userId, curationId)) + .thenReturn(Optional.of(existingLike)); + + // when + curationLikeService.CurationLikeOrUnlike(userId, curationId); + + // then + assertThat(mockCuration.getLikeCount()).isEqualTo(0); // 음수 아님 + } + + @Test + @DisplayName("비관적 락 사용 확인") + void likeCuration_usesPessimisticLock() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder().id(userId).build(); + Curation mockCuration = Curation.builder().id(curationId).likeCount(0).build(); + + when(curationRepository.findByIdWithLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(curationLikeRepository.findByUserIdAndCurationId(userId, curationId)) + .thenReturn(Optional.empty()); + when(curationLikeRepository.save(any())).thenReturn(CurationLike.builder().build()); + + // when + curationLikeService.CurationLikeOrUnlike(userId, curationId); + + // then + verify(curationRepository).findByIdWithLock(curationId); // 비관적 락 메서드 사용 + verify(curationRepository, never()).findById(curationId); // 일반 findById 사용 안함 + } +} diff --git a/src/test/java/BookPick/mvp/domain/curation/service/CurationReadServiceTest.java b/src/test/java/BookPick/mvp/domain/curation/service/CurationReadServiceTest.java new file mode 100644 index 0000000..d3b8a8e --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/curation/service/CurationReadServiceTest.java @@ -0,0 +1,299 @@ +package BookPick.mvp.domain.curation.service; + +import BookPick.mvp.domain.auth.service.CustomUserDetails; +import BookPick.mvp.domain.curation.dto.base.get.one.CurationGetRes; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.entity.CurationLike; +import BookPick.mvp.domain.curation.exception.common.CurationAccessDeniedException; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.curation.service.base.read.CurationReadService; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.domain.user.service.subscribe.CurationSubscribeService; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("큐레이션 조회 서비스 테스트") +class CurationReadServiceTest { + + @InjectMocks + private CurationReadService curationReadService; + + @Mock + private CurationRepository curationRepository; + + @Mock + private CurationLikeRepository curationLikeRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private CurationSubscribeService curationSubscribeService; + + @Mock + private HttpServletRequest request; + + @Test + @DisplayName("큐레이션 조회 성공 - 비로그인 사용자") + void findCuration_success_notLoggedIn() { + // given + Long curationId = 1L; + User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .nickname("테스터") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(mockUser) + .title("테스트 큐레이션") + .isDrafted(false) + .viewCount(0) + .likeCount(0) + .commentCount(0) + .moods(List.of("감동적인")) + .genres(List.of("소설")) + .keywords(List.of("사랑")) + .styles(List.of("감성적인")) + .build(); + + when(curationRepository.findByIdWithUserAndLock(curationId)).thenReturn(Optional.of(mockCuration)); + + // when + CurationGetRes result = curationReadService.findCuration(curationId, null, request, false); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(curationId); + assertThat(result.title()).isEqualTo("테스트 큐레이션"); + assertThat(result.viewCount()).isEqualTo(1); // 조회수 증가 확인 + assertThat(result.isLiked()).isFalse(); + assertThat(result.subscribed()).isFalse(); + + verify(curationRepository).findByIdWithUserAndLock(curationId); + verify(curationLikeRepository, never()).findByUserIdAndCurationId(any(), any()); + } + + @Test + @DisplayName("큐레이션 조회 성공 - 로그인 사용자, 좋아요 O, 구독 O") + void findCuration_success_loggedIn_likedAndSubscribed() { + // given + Long curationId = 1L; + Long userId = 2L; + + User curator = User.builder() + .id(1L) + .email("curator@test.com") + .nickname("큐레이터") + .build(); + + User viewer = User.builder() + .id(userId) + .email("viewer@test.com") + .nickname("뷰어") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(curator) + .title("테스트 큐레이션") + .isDrafted(false) + .viewCount(5) + .likeCount(10) + .commentCount(3) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + CurationLike mockLike = new CurationLike(); + + CustomUserDetails userDetails = mock(CustomUserDetails.class); + when(userDetails.getId()).thenReturn(userId); + + when(curationRepository.findByIdWithUserAndLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(curationLikeRepository.findByUserIdAndCurationId(userId, curationId)).thenReturn(Optional.of(mockLike)); + when(curationSubscribeService.isSubscribeCurator(userId, curator.getId())).thenReturn(true); + + // when + CurationGetRes result = curationReadService.findCuration(curationId, userDetails, request, false); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(curationId); + assertThat(result.viewCount()).isEqualTo(6); // 조회수 증가 확인 + assertThat(result.isLiked()).isTrue(); // 좋아요 확인 + assertThat(result.subscribed()).isTrue(); // 구독 확인 + + verify(curationRepository).findByIdWithUserAndLock(curationId); + verify(curationLikeRepository).findByUserIdAndCurationId(userId, curationId); + verify(curationSubscribeService).isSubscribeCurator(userId, curator.getId()); + } + + @Test + @DisplayName("큐레이션 조회 성공 - 로그인 사용자, 좋아요 X, 구독 X") + void findCuration_success_loggedIn_notLikedAndNotSubscribed() { + // given + Long curationId = 1L; + Long userId = 2L; + + User curator = User.builder() + .id(1L) + .email("curator@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(curator) + .title("테스트 큐레이션") + .isDrafted(false) + .viewCount(0) + .likeCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + CustomUserDetails userDetails = mock(CustomUserDetails.class); + when(userDetails.getId()).thenReturn(userId); + + when(curationRepository.findByIdWithUserAndLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(curationLikeRepository.findByUserIdAndCurationId(userId, curationId)).thenReturn(Optional.empty()); + when(curationSubscribeService.isSubscribeCurator(userId, curator.getId())).thenReturn(false); + + // when + CurationGetRes result = curationReadService.findCuration(curationId, userDetails, request, false); + + // then + assertThat(result.isLiked()).isFalse(); + assertThat(result.subscribed()).isFalse(); + + verify(curationLikeRepository).findByUserIdAndCurationId(userId, curationId); + verify(curationSubscribeService).isSubscribeCurator(userId, curator.getId()); + } + + @Test + @DisplayName("큐레이션 수정용 조회 성공 - 작성자") + void findCuration_editMode_success_owner() { + // given + Long curationId = 1L; + Long userId = 1L; + + User owner = User.builder() + .id(userId) + .email("owner@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(owner) + .title("테스트 큐레이션") + .isDrafted(true) + .viewCount(0) + .likeCount(0) + .commentCount(0) + .bookTitle("책 제목") + .bookAuthor("작가") + .bookIsbn("1234567890") + .bookImageUrl("book.jpg") + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + CustomUserDetails userDetails = mock(CustomUserDetails.class); + when(userDetails.getId()).thenReturn(userId); + + when(curationRepository.findByIdWithUserAndLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(curationLikeRepository.findByUserIdAndCurationId(userId, curationId)).thenReturn(Optional.empty()); + when(curationSubscribeService.isSubscribeCurator(userId, owner.getId())).thenReturn(false); + + // when + CurationGetRes result = curationReadService.findCuration(curationId, userDetails, request, true); + + // then + assertThat(result).isNotNull(); + assertThat(result.book()).isNotNull(); // 작성자는 책 정보 포함 + assertThat(result.book().title()).isEqualTo("책 제목"); + + verify(curationRepository).findByIdWithUserAndLock(curationId); + } + + @Test + @DisplayName("큐레이션 수정용 조회 실패 - 작성자가 아님") + void findCuration_editMode_fail_notOwner() { + // given + Long curationId = 1L; + Long ownerId = 1L; + Long viewerId = 2L; + + User owner = User.builder() + .id(ownerId) + .email("owner@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(owner) + .title("테스트 큐레이션") + .isDrafted(false) + .viewCount(0) + .likeCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + CustomUserDetails userDetails = mock(CustomUserDetails.class); + when(userDetails.getId()).thenReturn(viewerId); + + when(curationRepository.findByIdWithUserAndLock(curationId)).thenReturn(Optional.of(mockCuration)); + when(curationLikeRepository.findByUserIdAndCurationId(viewerId, curationId)).thenReturn(Optional.empty()); + when(curationSubscribeService.isSubscribeCurator(viewerId, ownerId)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> curationReadService.findCuration(curationId, userDetails, request, true)) + .isInstanceOf(CurationAccessDeniedException.class); + + verify(curationRepository).findByIdWithUserAndLock(curationId); + } + + @Test + @DisplayName("존재하지 않는 큐레이션 조회 - 예외 발생") + void findCuration_notFound() { + // given + Long curationId = 999L; + + when(curationRepository.findByIdWithUserAndLock(curationId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> curationReadService.findCuration(curationId, null, request, false)) + .isInstanceOf(CurationNotFoundException.class); + + verify(curationRepository).findByIdWithUserAndLock(curationId); + verify(curationLikeRepository, never()).findByUserIdAndCurationId(any(), any()); + } +} diff --git a/src/test/java/BookPick/mvp/domain/curation/service/CurationUpdateServiceTest.java b/src/test/java/BookPick/mvp/domain/curation/service/CurationUpdateServiceTest.java new file mode 100644 index 0000000..f875d8d --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/curation/service/CurationUpdateServiceTest.java @@ -0,0 +1,381 @@ +package BookPick.mvp.domain.curation.service; + +import BookPick.mvp.domain.curation.dto.base.create.ETC.BookDto; +import BookPick.mvp.domain.curation.dto.base.create.ETC.RecommendDto; +import BookPick.mvp.domain.curation.dto.base.create.ETC.ThumbnailDto; +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateReq; +import BookPick.mvp.domain.curation.dto.base.update.CurationUpdateResult; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.exception.common.CurationAccessDeniedException; +import BookPick.mvp.domain.curation.exception.common.CurationAlreadyPublishedException; +import BookPick.mvp.domain.curation.exception.common.CurationNotFoundException; +import BookPick.mvp.domain.curation.repository.CurationRepository; +import BookPick.mvp.domain.curation.repository.like.CurationLikeRepository; +import BookPick.mvp.domain.curation.service.base.update.CurationUpdateService; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.domain.user.service.subscribe.CurationSubscribeService; +import BookPick.mvp.global.api.SuccessCode.SuccessCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("큐레이션 수정 서비스 테스트") +class CurationUpdateServiceTest { + + @InjectMocks + private CurationUpdateService curationUpdateService; + + @Mock + private CurationRepository curationRepository; + + @Mock + private CurationLikeRepository curationLikeRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private CurationSubscribeService curationSubscribeService; + + @Test + @DisplayName("임시저장 -> 임시저장 수정 성공") + void updateCuration_draftToDraft_success() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(mockUser) + .title("기존 제목") + .isDrafted(true) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + CurationUpdateReq req = new CurationUpdateReq( + "수정된 제목", + new ThumbnailDto("new-thumb.jpg", "#000000"), + new BookDto("새 책", "새 작가", "9876543210", "new-book.jpg"), + "수정된 리뷰", + new RecommendDto(List.of("슬픈"), List.of("에세이"), List.of("이별"), List.of("담백한")), + true // isDrafted = true + ); + + when(curationRepository.findById(curationId)).thenReturn(Optional.of(mockCuration)); + + // when + CurationUpdateResult result = curationUpdateService.updateCuration(userId, curationId, req); + + // then + assertThat(result).isNotNull(); + assertThat(result.successCode()).isEqualTo(SuccessCode.CURATION_DRAFT_UPDATE_SUCCESS); + assertThat(result.curationUpdateRes().id()).isEqualTo(curationId); + assertThat(mockCuration.getTitle()).isEqualTo("수정된 제목"); + assertThat(mockCuration.getIsDrafted()).isTrue(); + + verify(curationRepository).findById(curationId); + } + + @Test + @DisplayName("임시저장 -> 발행 성공") + void updateCuration_draftToPublished_success() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(mockUser) + .title("임시저장 제목") + .isDrafted(true) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + CurationUpdateReq req = new CurationUpdateReq( + "발행할 제목", + new ThumbnailDto("thumb.jpg", "#FFFFFF"), + new BookDto("책", "작가", "123", "book.jpg"), + "리뷰", + new RecommendDto(List.of(), List.of(), List.of(), List.of()), + false // isDrafted = false (발행) + ); + + when(curationRepository.findById(curationId)).thenReturn(Optional.of(mockCuration)); + + // when + CurationUpdateResult result = curationUpdateService.updateCuration(userId, curationId, req); + + // then + assertThat(result).isNotNull(); + assertThat(result.successCode()).isEqualTo(SuccessCode.DRAFTED_CURATION_PUBLISH_SUCCESS); + assertThat(mockCuration.getIsDrafted()).isFalse(); + assertThat(mockCuration.getPublishedAt()).isNotNull(); + + verify(curationRepository).findById(curationId); + } + + @Test + @DisplayName("발행본 -> 발행본 수정 성공") + void updateCuration_publishedToPublished_success() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(mockUser) + .title("발행된 제목") + .isDrafted(false) + .likeCount(5) + .viewCount(100) + .commentCount(10) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + CurationUpdateReq req = new CurationUpdateReq( + "수정된 발행 제목", + new ThumbnailDto("thumb.jpg", "#FFFFFF"), + new BookDto("책", "작가", "123", "book.jpg"), + "수정된 리뷰", + new RecommendDto(List.of(), List.of(), List.of(), List.of()), + false // isDrafted = false + ); + + when(curationRepository.findById(curationId)).thenReturn(Optional.of(mockCuration)); + + // when + CurationUpdateResult result = curationUpdateService.updateCuration(userId, curationId, req); + + // then + assertThat(result).isNotNull(); + assertThat(result.successCode()).isEqualTo(SuccessCode.CURATION_UPDATE_SUCCESS); + assertThat(mockCuration.getTitle()).isEqualTo("수정된 발행 제목"); + assertThat(mockCuration.getIsDrafted()).isFalse(); + + verify(curationRepository).findById(curationId); + } + + @Test + @DisplayName("발행본 -> 임시저장 변경 시도 - 예외 발생") + void updateCuration_publishedToDraft_fail() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder() + .id(userId) + .email("test@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(mockUser) + .title("발행된 제목") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + CurationUpdateReq req = new CurationUpdateReq( + "제목", + new ThumbnailDto("thumb.jpg", "#FFFFFF"), + new BookDto("책", "작가", "123", "book.jpg"), + "리뷰", + new RecommendDto(List.of(), List.of(), List.of(), List.of()), + true // isDrafted = true (임시저장으로 변경 시도) + ); + + when(curationRepository.findById(curationId)).thenReturn(Optional.of(mockCuration)); + + // when & then + assertThatThrownBy(() -> curationUpdateService.updateCuration(userId, curationId, req)) + .isInstanceOf(CurationAlreadyPublishedException.class); + + verify(curationRepository).findById(curationId); + } + + @Test + @DisplayName("다른 사용자가 수정 시도 - 예외 발생") + void updateCuration_notOwner_fail() { + // given + Long ownerId = 1L; + Long attackerId = 2L; + Long curationId = 1L; + + User owner = User.builder() + .id(ownerId) + .email("owner@test.com") + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(owner) + .title("제목") + .isDrafted(true) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of()) + .genres(List.of()) + .keywords(List.of()) + .styles(List.of()) + .build(); + + CurationUpdateReq req = new CurationUpdateReq( + "해킹 시도", + new ThumbnailDto("thumb.jpg", "#FFFFFF"), + new BookDto("책", "작가", "123", "book.jpg"), + "리뷰", + new RecommendDto(List.of(), List.of(), List.of(), List.of()), + true + ); + + when(curationRepository.findById(curationId)).thenReturn(Optional.of(mockCuration)); + + // when & then + assertThatThrownBy(() -> curationUpdateService.updateCuration(attackerId, curationId, req)) + .isInstanceOf(CurationAccessDeniedException.class); + + verify(curationRepository).findById(curationId); + } + + @Test + @DisplayName("존재하지 않는 큐레이션 수정 - 예외 발생") + void updateCuration_notFound_fail() { + // given + Long userId = 1L; + Long curationId = 999L; + + CurationUpdateReq req = new CurationUpdateReq( + "제목", + new ThumbnailDto("thumb.jpg", "#FFFFFF"), + new BookDto("책", "작가", "123", "book.jpg"), + "리뷰", + new RecommendDto(List.of(), List.of(), List.of(), List.of()), + false + ); + + when(curationRepository.findById(curationId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> curationUpdateService.updateCuration(userId, curationId, req)) + .isInstanceOf(CurationNotFoundException.class); + + verify(curationRepository).findById(curationId); + } + + @Test + @DisplayName("큐레이션 업데이트 시 내용 변경 확인") + void updateCuration_contentChange_success() { + // given + Long userId = 1L; + Long curationId = 1L; + + User mockUser = User.builder() + .id(userId) + .build(); + + Curation mockCuration = Curation.builder() + .id(curationId) + .user(mockUser) + .title("기존 제목") + .thumbnailUrl("old-thumb.jpg") + .thumbnailColor("#FFFFFF") + .bookTitle("기존 책") + .bookAuthor("기존 작가") + .bookIsbn("111") + .bookImageUrl("old-book.jpg") + .review("기존 리뷰") + .isDrafted(false) + .likeCount(0) + .viewCount(0) + .commentCount(0) + .moods(List.of("기쁜")) + .genres(List.of("소설")) + .keywords(List.of("사랑")) + .styles(List.of("감성적인")) + .build(); + + CurationUpdateReq req = new CurationUpdateReq( + "새 제목", + new ThumbnailDto("new-thumb.jpg", "#000000"), + new BookDto("새 책", "새 작가", "222", "new-book.jpg"), + "새 리뷰", + new RecommendDto( + List.of("슬픈"), + List.of("에세이"), + List.of("이별"), + List.of("담백한") + ), + false + ); + + when(curationRepository.findById(curationId)).thenReturn(Optional.of(mockCuration)); + + // when + CurationUpdateResult result = curationUpdateService.updateCuration(userId, curationId, req); + + // then + assertThat(mockCuration.getTitle()).isEqualTo("새 제목"); + assertThat(mockCuration.getThumbnailUrl()).isEqualTo("new-thumb.jpg"); + assertThat(mockCuration.getThumbnailColor()).isEqualTo("#000000"); + assertThat(mockCuration.getBookTitle()).isEqualTo("새 책"); + assertThat(mockCuration.getBookAuthor()).isEqualTo("새 작가"); + assertThat(mockCuration.getBookIsbn()).isEqualTo("222"); + assertThat(mockCuration.getBookImageUrl()).isEqualTo("new-book.jpg"); + assertThat(mockCuration.getReview()).isEqualTo("새 리뷰"); + assertThat(mockCuration.getMoods()).containsExactly("슬픈"); + assertThat(mockCuration.getGenres()).containsExactly("에세이"); + assertThat(mockCuration.getKeywords()).containsExactly("이별"); + assertThat(mockCuration.getStyles()).containsExactly("담백한"); + + verify(curationRepository).findById(curationId); + } +} diff --git a/src/test/java/BookPick/mvp/domain/curation/util/converter/StringListConverterTest.java b/src/test/java/BookPick/mvp/domain/curation/util/converter/StringListConverterTest.java new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/BookPick/mvp/domain/curation/util/gemini/service/GeminiServiceTest.java b/src/test/java/BookPick/mvp/domain/curation/util/gemini/service/GeminiServiceTest.java new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/BookPick/mvp/domain/curation/util/list/Handler/CurationPageHandlerTest.java b/src/test/java/BookPick/mvp/domain/curation/util/list/Handler/CurationPageHandlerTest.java new file mode 100644 index 0000000..3c94319 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/curation/util/list/Handler/CurationPageHandlerTest.java @@ -0,0 +1,93 @@ +package BookPick.mvp.domain.curation.util.list.Handler; + +import BookPick.mvp.domain.curation.dto.base.get.list.CurationContentRes; +import BookPick.mvp.domain.curation.dto.base.get.list.CursorPage; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.curation.util.list.fetcher.CurationFetcher; +import BookPick.mvp.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CurationPageHandler 테스트") +class CurationPageHandlerTest { + + @InjectMocks + private CurationPageHandler curationPageHandler; + + @Mock + private CurationFetcher curationFetcher; + + @Test + @DisplayName("다음 페이지가 있는 경우 커서 페이지 생성") + void createCursorPage_withNextPage() { + // given + List curations = IntStream.range(0, 11) + .mapToObj(i -> Curation.builder().id((long) i).build()) + .collect(Collectors.toList()); + int size = 10; + when(curationFetcher.calculateNextCursor(curations, size, true)).thenReturn(10L); + + // when + CursorPage cursorPage = curationPageHandler.createCursorPage(curations, size); + + // then + assertThat(cursorPage.isHasNext()).isTrue(); + assertThat(cursorPage.getContent()).hasSize(10); + assertThat(cursorPage.getNextCursor()).isEqualTo(10L); + } + + @Test + @DisplayName("다음 페이지가 없는 경우 커서 페이지 생성") + void createCursorPage_withoutNextPage() { + // given + List curations = IntStream.range(0, 5) + .mapToObj(i -> Curation.builder().id((long) i).build()) + .collect(Collectors.toList()); + int size = 10; + when(curationFetcher.calculateNextCursor(curations, size, false)).thenReturn(null); + + // when + CursorPage cursorPage = curationPageHandler.createCursorPage(curations, size); + + // then + assertThat(cursorPage.isHasNext()).isFalse(); + assertThat(cursorPage.getContent()).hasSize(5); + assertThat(cursorPage.getNextCursor()).isNull(); + } + + @Test + @DisplayName("큐레이션 리스트를 DTO로 변환") + void convertToContentRes_conversion() { + // given + User user = User.builder().id(1L).build(); + Curation c1 = Curation.builder().id(1L).user(user).title("title1").build(); + Curation c2 = Curation.builder().id(2L).user(user).title("title2").build(); + Curation c3 = Curation.builder().id(3L).user(user).title("title3").build(); + List curations = List.of(c1, c2, c3); + Set likedIds = Set.of(1L, 3L); + + // when + List res = curationPageHandler.convertToContentRes(curations, likedIds); + + // then + assertThat(res).hasSize(3); + assertThat(res.get(0).isLiked()).isTrue(); + assertThat(res.get(1).isLiked()).isFalse(); + assertThat(res.get(2).isLiked()).isTrue(); + } +} diff --git a/src/test/java/BookPick/mvp/domain/curation/util/list/fetcher/CurationFetcherTest.java b/src/test/java/BookPick/mvp/domain/curation/util/list/fetcher/CurationFetcherTest.java new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/BookPick/mvp/domain/curation/util/list/similarity/CurationMatchResultPaginationTest.java b/src/test/java/BookPick/mvp/domain/curation/util/list/similarity/CurationMatchResultPaginationTest.java new file mode 100644 index 0000000..a2ab5af --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/curation/util/list/similarity/CurationMatchResultPaginationTest.java @@ -0,0 +1,101 @@ +package BookPick.mvp.domain.curation.util.list.similarity; + +import BookPick.mvp.domain.curation.util.gemini.dto.CurationMatchResult; +import BookPick.mvp.domain.curation.entity.Curation; +import BookPick.mvp.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("CurationMatchResultPagination 테스트") +class CurationMatchResultPaginationTest { + + @Test + @DisplayName("커서 기반 페이지네이션") + void paginate_withCursor() { + // given + User dummyUser = User.builder().id(99L).build(); // Dummy user for CurationMatchResult builder + List results = IntStream.range(0, 20) + .mapToObj(i -> CurationMatchResult.builder() + .curation(Curation.builder().id((long) i).build()) + .user(dummyUser) + .totalMatchCount(i) + .matchedMood("mood") + .matchedGenre("genre") + .matchedKeyword("keyword") + .matchedStyle("style") + .matched("matched") + .build()) + .collect(Collectors.toList()); + Pageable pageable = PageRequest.of(0, 5); + Long cursor = 10L; + + // when + List paginated = CurationMatchResultPagination.paginate(results, cursor, pageable); + + // then + assertThat(paginated).hasSize(5); + assertThat(paginated.get(0).getCuration().getId()).isEqualTo(10L); + } + + @Test + @DisplayName("커서가 null인 경우") + void paginate_nullCursor() { + // given + User dummyUser = User.builder().id(99L).build(); // Dummy user for CurationMatchResult builder + List results = IntStream.range(0, 20) + .mapToObj(i -> CurationMatchResult.builder() + .curation(Curation.builder().id((long) i).build()) + .user(dummyUser) + .totalMatchCount(i) + .matchedMood("mood") + .matchedGenre("genre") + .matchedKeyword("keyword") + .matchedStyle("style") + .matched("matched") + .build()) + .collect(Collectors.toList()); + Pageable pageable = PageRequest.of(0, 5); + + // when + List paginated = CurationMatchResultPagination.paginate(results, null, pageable); + + // then + assertThat(paginated).hasSize(5); + assertThat(paginated.get(0).getCuration().getId()).isEqualTo(0L); + } + + @Test + @DisplayName("커서가 범위를 벗어나는 경우") + void paginate_cursorOutOfBounds() { + // given + User dummyUser = User.builder().id(99L).build(); // Dummy user for CurationMatchResult builder + List results = IntStream.range(0, 10) + .mapToObj(i -> CurationMatchResult.builder() + .curation(Curation.builder().id((long) i).build()) + .user(dummyUser) + .totalMatchCount(i) + .matchedMood("mood") + .matchedGenre("genre") + .matchedKeyword("keyword") + .matchedStyle("style") + .matched("matched") + .build()) + .collect(Collectors.toList()); + Pageable pageable = PageRequest.of(0, 5); + Long cursor = 15L; + + // when + List paginated = CurationMatchResultPagination.paginate(results, cursor, pageable); + + // then + assertThat(paginated).isEmpty(); + } +} diff --git a/src/test/java/BookPick/mvp/domain/user/service/subscribe/CurationSubscribeServiceTest.java b/src/test/java/BookPick/mvp/domain/user/service/subscribe/CurationSubscribeServiceTest.java new file mode 100644 index 0000000..59df197 --- /dev/null +++ b/src/test/java/BookPick/mvp/domain/user/service/subscribe/CurationSubscribeServiceTest.java @@ -0,0 +1,141 @@ +package BookPick.mvp.domain.user.service.subscribe; + +import BookPick.mvp.domain.user.dto.subscribe.CuratorSubscribeReq; +import BookPick.mvp.domain.user.dto.subscribe.CuratorSubscribeRes; +import BookPick.mvp.domain.user.dto.subscribe.SubscribedCuratorPageRes; +import BookPick.mvp.domain.user.entity.CuratorSubscribe; +import BookPick.mvp.domain.user.entity.User; +import BookPick.mvp.domain.user.exception.curator.CuratorNotFoundException; +import BookPick.mvp.domain.user.exception.common.UserNotFoundException; +import BookPick.mvp.domain.user.repository.UserRepository; +import BookPick.mvp.domain.user.repository.subscribe.CurationSubscribeRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("큐레이터 구독 서비스 테스트") +class CurationSubscribeServiceTest { + + @InjectMocks + private CurationSubscribeService curationSubscribeService; + + @Mock + private UserRepository userRepository; + + @Mock + private CurationSubscribeRepository curationSubscribeRepository; + + @Test + @DisplayName("큐레이터 구독 성공") + void subscribe_success_newSubscription() { + // given + Long userId = 1L; + Long curatorId = 2L; + CuratorSubscribeReq req = new CuratorSubscribeReq(curatorId); + User user = User.builder().id(userId).build(); + User curator = User.builder().id(curatorId).nickname("curator").build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userRepository.findById(curatorId)).thenReturn(Optional.of(curator)); + when(curationSubscribeRepository.findByUserIdAndCuratorId(userId, curatorId)).thenReturn(Optional.empty()); + + // when + CuratorSubscribeRes res = curationSubscribeService.subscribe(userId, req); + + // then + assertThat(res.subscribed()).isTrue(); + assertThat(res.curatorId()).isEqualTo(curatorId); + verify(curationSubscribeRepository).save(any(CuratorSubscribe.class)); + } + + @Test + @DisplayName("큐레이터 구독 취소 성공") + void subscribe_success_cancelSubscription() { + // given + Long userId = 1L; + Long curatorId = 2L; + CuratorSubscribeReq req = new CuratorSubscribeReq(curatorId); + User user = User.builder().id(userId).build(); + User curator = User.builder().id(curatorId).nickname("curator").build(); + CuratorSubscribe existingSubscription = CuratorSubscribe.builder().user(user).curator(curator).build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userRepository.findById(curatorId)).thenReturn(Optional.of(curator)); + when(curationSubscribeRepository.findByUserIdAndCuratorId(userId, curatorId)).thenReturn(Optional.of(existingSubscription)); + + // when + CuratorSubscribeRes res = curationSubscribeService.subscribe(userId, req); + + // then + assertThat(res.subscribed()).isFalse(); + verify(curationSubscribeRepository).delete(existingSubscription); + } + + @Test + @DisplayName("구독 여부 확인 - 구독 중") + void isSubscribeCurator_true() { + // given + Long userId = 1L; + Long curatorId = 2L; + when(curationSubscribeRepository.findByUserIdAndCuratorId(userId, curatorId)).thenReturn(Optional.of(new CuratorSubscribe())); + + // when + boolean isSubscribed = curationSubscribeService.isSubscribeCurator(userId, curatorId); + + // then + assertThat(isSubscribed).isTrue(); + } + + @Test + @DisplayName("구독 여부 확인 - 구독 안함") + void isSubscribeCurator_false() { + // given + Long userId = 1L; + Long curatorId = 2L; + when(curationSubscribeRepository.findByUserIdAndCuratorId(userId, curatorId)).thenReturn(Optional.empty()); + + // when + boolean isSubscribed = curationSubscribeService.isSubscribeCurator(userId, curatorId); + + // then + assertThat(isSubscribed).isFalse(); + } + + @Test + @DisplayName("구독한 큐레이터 목록 조회") + void getSubscribedCurators_success() { + // given + Long userId = 1L; + Pageable pageable = PageRequest.of(0, 10); + User curatorUser = User.builder().id(2L).nickname("curator1").build(); + CuratorSubscribe subscription = CuratorSubscribe.builder().curator(curatorUser).build(); + Page page = new PageImpl<>(Collections.singletonList(subscription), pageable, 1); + + when(curationSubscribeRepository.findByUserIdOrderByIdDesc(userId, pageable)).thenReturn(page); + + // when + SubscribedCuratorPageRes res = curationSubscribeService.getSubscribedCurators(userId, 0, 10); + + // then + assertThat(res.curators()).hasSize(1); + assertThat(res.curators().get(0).nickname()).isEqualTo("curator1"); + assertThat(res.pageInfo().hasNext()).isFalse(); + } +}