diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md new file mode 100644 index 0000000..3c17a06 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -0,0 +1,17 @@ +--- +name: "[FEAT] ISSUE" +about: Create feature issue +title: "[FEAT] feature issue title" +labels: '' +assignees: '' + +--- + +## ๐Ÿ”ฅDescription + +- description + + +## โœ… Todo + +- [ ] todo \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..270b24d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## ๐Ÿ”ฅ Related Issue + +- Close #์ด์Šˆ๋ฒˆํ˜ธ + + +## ๐Ÿ“‘ Task + +- ์ž‘์—…๋‚ด์šฉ + + +## ๐Ÿ” To Reviewer + +- ์ง‘์ค‘์ ์œผ๋กœ ๋ฆฌ๋ทฐํ•ด์•ผ๋  ๋ถ€๋ถ„ ์žˆ์œผ๋ฉด ์ž‘์„ฑ \ No newline at end of file diff --git a/.github/workflows/develop-ci.yml b/.github/workflows/develop-ci.yml new file mode 100644 index 0000000..b8f4e47 --- /dev/null +++ b/.github/workflows/develop-ci.yml @@ -0,0 +1,47 @@ +name: Develop CI + +on: + pull_request: + branches: [ "develop" ] + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: ์ฝ”๋“œ ์ฒดํฌ์•„์›ƒ + uses: actions/checkout@v4 + + - name: JDK 17 ์‹œ์ž‘ + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: test ๊ฒฝ๋กœ application.yml ํŒŒ์ผ ์ƒ์„ฑ + run: | + mkdir -p ./src/test/resources + echo "${{ secrets.APPLICATION_TEST }}" > ./src/test/resources/application.yml + + - name: Gradle ์บ์‹œ ์ ์šฉ + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Gradle ๊ถŒํ•œ ๋ถ€์—ฌ + run: chmod +x ./gradlew + + - name: Gradle ๋นŒ๋“œ ์‹œ์ž‘ + run: ./gradlew clean build --no-daemon + + - name: Gradle ํ…Œ์ŠคํŠธ ์‹œ์ž‘ + run: ./gradlew clean test --no-daemon \ No newline at end of file diff --git a/.github/workflows/prod-cicd.yml b/.github/workflows/prod-cicd.yml new file mode 100644 index 0000000..d5dc3ff --- /dev/null +++ b/.github/workflows/prod-cicd.yml @@ -0,0 +1,79 @@ +name: Prod CI/CD + +on: + pull_request: + branches: [ "prod" ] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Create test application.yml + run: | + rm -rf ./src/test/resources + mkdir -p ./src/test/resources + echo "${{ secrets.APPLICATION_TEST }}" > ./src/test/resources/application.yml + + - name: Create main application.yml + run: | + rm -rf ./src/main/resources + mkdir -p ./src/main/resources + echo "${{ secrets.APPLICATION }}" > ./src/main/resources/application.yml + + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew clean build + + - name: Build the Docker image + run: docker build . --file Dockerfile --tag ${{ secrets.DOCKER_USERNAME }}/pop:latest + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Push the Docker image + run: docker push ${{ secrets.DOCKER_USERNAME }}/pop:latest + + deploy: + runs-on: ubuntu-latest + needs: build + + steps: + - name: Deploy to AWS EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_KEY }} + port: 22 + script: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_HUB_TOKEN }} + docker pull ${{ secrets.DOCKER_USERNAME }}/pop:latest + docker stop pop_server || true + docker rm pop_server || true + docker run -d --name pop_server -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/pop:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc01f20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +application.yml + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cb33e1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17-jdk +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..be36740 --- /dev/null +++ b/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.12' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.cake' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework:spring-core' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' + + runtimeOnly 'com.mysql:mysql-connector-j' + + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + + // test + testRuntimeOnly 'com.h2database:h2' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..a44c279 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'pop' diff --git a/src/main/java/com/cake/pop/PopApplication.java b/src/main/java/com/cake/pop/PopApplication.java new file mode 100644 index 0000000..5c316f6 --- /dev/null +++ b/src/main/java/com/cake/pop/PopApplication.java @@ -0,0 +1,15 @@ +package com.cake.pop; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class PopApplication { + + public static void main(String[] args) { + SpringApplication.run(PopApplication.class, args); + } + +} diff --git a/src/main/java/com/cake/pop/domain/letter/controller/LetterController.java b/src/main/java/com/cake/pop/domain/letter/controller/LetterController.java new file mode 100644 index 0000000..4f81ea8 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/controller/LetterController.java @@ -0,0 +1,65 @@ +package com.cake.pop.domain.letter.controller; + +import com.cake.pop.domain.letter.dto.request.CreateLetterRequest; +import com.cake.pop.domain.letter.dto.request.CreateStorageRequest; +import com.cake.pop.domain.letter.dto.response.GetLetterResponse; +import com.cake.pop.domain.letter.dto.response.GetLettersResponse; +import com.cake.pop.domain.letter.dto.response.SimpleLetterDto; +import com.cake.pop.domain.letter.service.LetterService; +import jakarta.validation.Valid; +import java.util.List; +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.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/letters") +@RequiredArgsConstructor +public class LetterController { + + private final LetterService letterService; + + @PostMapping + public ResponseEntity createLetter(@RequestBody @Valid CreateLetterRequest request) { + letterService.createLetter(request); + return ResponseEntity.ok().build(); + } + + @GetMapping + public ResponseEntity getLetters(@RequestParam("region") String region) { + GetLettersResponse response = letterService.getLetters(region); + return ResponseEntity.ok(response); + } + + @GetMapping("/{letterId}") + public ResponseEntity getLetter(@PathVariable("letterId") Long letterId) { + GetLetterResponse response = letterService.getLetter(letterId); + return ResponseEntity.ok(response); + } + + @PostMapping("/storage") + public ResponseEntity createStorage(@AuthenticationPrincipal Long userId, @RequestBody @Valid CreateStorageRequest request) { + letterService.createStorage(userId, request); + return ResponseEntity.ok().build(); + } + + @GetMapping("/storages") + public ResponseEntity> getStorages(@AuthenticationPrincipal Long userId) { + List response = letterService.getStorages(userId); + return ResponseEntity.ok(response); + } + + @PatchMapping("/{letterId}/report") + public ResponseEntity reportLetter(@PathVariable("letterId") Long letterId) { + letterService.reportLetter(letterId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/cake/pop/domain/letter/controller/MailboxController.java b/src/main/java/com/cake/pop/domain/letter/controller/MailboxController.java new file mode 100644 index 0000000..5128537 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/controller/MailboxController.java @@ -0,0 +1,24 @@ +package com.cake.pop.domain.letter.controller; + +import com.cake.pop.domain.letter.dto.response.GetRankResponse; +import com.cake.pop.domain.letter.service.MailboxService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/mailboxes") +@RequiredArgsConstructor +public class MailboxController { + + private final MailboxService mailboxService; + + @GetMapping("/rank") + public ResponseEntity> getRanks(){ + List response = mailboxService.getRanks(); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/cake/pop/domain/letter/dto/request/CreateLetterRequest.java b/src/main/java/com/cake/pop/domain/letter/dto/request/CreateLetterRequest.java new file mode 100644 index 0000000..455b7f3 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/dto/request/CreateLetterRequest.java @@ -0,0 +1,16 @@ +package com.cake.pop.domain.letter.dto.request; + +import com.cake.pop.entity.enums.Region; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CreateLetterRequest( + @NotBlank + @Size(min = 1, max = 350, message = "๋‚ด์šฉ์€ ์ตœ์†Œ 1์ž ์ตœ๋Œ€ 350์ž ์ž…๋‹ˆ๋‹ค.") + String content, + String imageUrl, + @NotNull + Region region +) { +} diff --git a/src/main/java/com/cake/pop/domain/letter/dto/request/CreateStorageRequest.java b/src/main/java/com/cake/pop/domain/letter/dto/request/CreateStorageRequest.java new file mode 100644 index 0000000..da0c3d2 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/dto/request/CreateStorageRequest.java @@ -0,0 +1,6 @@ +package com.cake.pop.domain.letter.dto.request; + +public record CreateStorageRequest( + Long letterId +) { +} diff --git a/src/main/java/com/cake/pop/domain/letter/dto/request/ReportLetterRequest.java b/src/main/java/com/cake/pop/domain/letter/dto/request/ReportLetterRequest.java new file mode 100644 index 0000000..12f9b0a --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/dto/request/ReportLetterRequest.java @@ -0,0 +1,4 @@ +package com.cake.pop.domain.letter.dto.request; + +public record ReportLetterRequest() { +} diff --git a/src/main/java/com/cake/pop/domain/letter/dto/request/StorageLetterRequest.java b/src/main/java/com/cake/pop/domain/letter/dto/request/StorageLetterRequest.java new file mode 100644 index 0000000..eb244cb --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/dto/request/StorageLetterRequest.java @@ -0,0 +1,4 @@ +package com.cake.pop.domain.letter.dto.request; + +public record StorageLetterRequest() { +} diff --git a/src/main/java/com/cake/pop/domain/letter/dto/response/GetLetterResponse.java b/src/main/java/com/cake/pop/domain/letter/dto/response/GetLetterResponse.java new file mode 100644 index 0000000..4160f1d --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/dto/response/GetLetterResponse.java @@ -0,0 +1,9 @@ +package com.cake.pop.domain.letter.dto.response; + +public record GetLetterResponse( + Long letterId, + String content, + String imageUrl, + String region +) { +} diff --git a/src/main/java/com/cake/pop/domain/letter/dto/response/GetLettersResponse.java b/src/main/java/com/cake/pop/domain/letter/dto/response/GetLettersResponse.java new file mode 100644 index 0000000..8360077 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/dto/response/GetLettersResponse.java @@ -0,0 +1,9 @@ +package com.cake.pop.domain.letter.dto.response; + +import java.util.List; + +public record GetLettersResponse( + String region, + List letters +) { +} diff --git a/src/main/java/com/cake/pop/domain/letter/dto/response/GetRankResponse.java b/src/main/java/com/cake/pop/domain/letter/dto/response/GetRankResponse.java new file mode 100644 index 0000000..2a0fe8c --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/dto/response/GetRankResponse.java @@ -0,0 +1,7 @@ +package com.cake.pop.domain.letter.dto.response; + +public record GetRankResponse( + String region, + Integer letterCount +) { +} diff --git a/src/main/java/com/cake/pop/domain/letter/dto/response/GetStoragesResponse.java b/src/main/java/com/cake/pop/domain/letter/dto/response/GetStoragesResponse.java new file mode 100644 index 0000000..1b39c7e --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/dto/response/GetStoragesResponse.java @@ -0,0 +1,4 @@ +package com.cake.pop.domain.letter.dto.response; + +public record GetStoragesResponse() { +} diff --git a/src/main/java/com/cake/pop/domain/letter/dto/response/SimpleLetterDto.java b/src/main/java/com/cake/pop/domain/letter/dto/response/SimpleLetterDto.java new file mode 100644 index 0000000..7d6cbf8 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/dto/response/SimpleLetterDto.java @@ -0,0 +1,12 @@ +package com.cake.pop.domain.letter.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDate; + +public record SimpleLetterDto( + Long letterId, + String content, + @JsonFormat(pattern = "yy.MM.dd") + LocalDate createdAt +) { +} diff --git a/src/main/java/com/cake/pop/domain/letter/exception/LetterErrorCode.java b/src/main/java/com/cake/pop/domain/letter/exception/LetterErrorCode.java new file mode 100644 index 0000000..3e987c7 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/exception/LetterErrorCode.java @@ -0,0 +1,18 @@ +package com.cake.pop.domain.letter.exception; + +import com.cake.pop.global.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum LetterErrorCode implements ErrorCode { + + LETTER_NOT_FOUND(HttpStatus.NOT_FOUND, "์ชฝ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + MAILBOX_NOT_FOUND(HttpStatus.NOT_FOUND, "์šฐ์ฒดํ†ต์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cake/pop/domain/letter/repository/LetterRepository.java b/src/main/java/com/cake/pop/domain/letter/repository/LetterRepository.java new file mode 100644 index 0000000..2608a6a --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/repository/LetterRepository.java @@ -0,0 +1,36 @@ +package com.cake.pop.domain.letter.repository; + +import com.cake.pop.domain.letter.exception.LetterErrorCode; +import com.cake.pop.entity.Letter; +import com.cake.pop.entity.Mailbox; +import com.cake.pop.global.exception.RestApiException; +import java.util.List; +import java.util.Optional; +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; + +@Repository +public interface LetterRepository extends JpaRepository { + + List findByMailbox(Mailbox mailbox); + + List findByMailboxAndStatus(Mailbox mailbox, com.cake.pop.entity.enums.Status status); + + @Query("SELECT l FROM Letter l WHERE l.id = :id AND l.status = 'ACTIVE'") + Optional findActiveById(@Param("id") Long id); + + @Query("SELECT l FROM Letter l JOIN FETCH l.mailbox WHERE l.id = :id AND l.status = 'ACTIVE'") + Optional findActiveByIdWithMailbox(@Param("id") Long id); + + default Letter getById(Long id) { + return findActiveById(id) + .orElseThrow(() -> new RestApiException(LetterErrorCode.LETTER_NOT_FOUND)); + } + + default Letter getByIdWithMailbox(Long id) { + return findActiveByIdWithMailbox(id) + .orElseThrow(() -> new RestApiException(LetterErrorCode.LETTER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/cake/pop/domain/letter/repository/MailboxRepository.java b/src/main/java/com/cake/pop/domain/letter/repository/MailboxRepository.java new file mode 100644 index 0000000..eb4942e --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/repository/MailboxRepository.java @@ -0,0 +1,22 @@ +package com.cake.pop.domain.letter.repository; + +import com.cake.pop.domain.letter.exception.LetterErrorCode; +import com.cake.pop.entity.Mailbox; +import com.cake.pop.entity.enums.Region; +import com.cake.pop.global.exception.RestApiException; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MailboxRepository extends JpaRepository { + Optional findFirstByRegion(Region region); + + List findTop3ByOrderByLetterCountDesc(); + + default Mailbox getFirstByRegion(Region region) { + return findFirstByRegion(region) + .orElseThrow(() -> new RestApiException(LetterErrorCode.MAILBOX_NOT_FOUND)); + } +} diff --git a/src/main/java/com/cake/pop/domain/letter/repository/StorageRepository.java b/src/main/java/com/cake/pop/domain/letter/repository/StorageRepository.java new file mode 100644 index 0000000..864659b --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/repository/StorageRepository.java @@ -0,0 +1,15 @@ +package com.cake.pop.domain.letter.repository; + +import com.cake.pop.entity.Storage; +import com.cake.pop.entity.User; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +@Repository +public interface StorageRepository extends JpaRepository { + + @Query("SELECT s FROM Storage s JOIN FETCH s.letter WHERE s.user = :user") + List findByUser(User user); +} diff --git a/src/main/java/com/cake/pop/domain/letter/service/LetterService.java b/src/main/java/com/cake/pop/domain/letter/service/LetterService.java new file mode 100644 index 0000000..603fac4 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/service/LetterService.java @@ -0,0 +1,99 @@ +package com.cake.pop.domain.letter.service; + +import com.cake.pop.domain.letter.dto.request.CreateLetterRequest; +import com.cake.pop.domain.letter.dto.request.CreateStorageRequest; +import com.cake.pop.domain.letter.dto.response.GetLetterResponse; +import com.cake.pop.domain.letter.dto.response.GetLettersResponse; +import com.cake.pop.domain.letter.dto.response.SimpleLetterDto; +import com.cake.pop.domain.letter.repository.LetterRepository; +import com.cake.pop.domain.letter.repository.MailboxRepository; +import com.cake.pop.domain.letter.repository.StorageRepository; +import com.cake.pop.domain.user.repository.UserRepository; +import com.cake.pop.entity.Letter; +import com.cake.pop.entity.Mailbox; +import com.cake.pop.entity.Storage; +import com.cake.pop.entity.User; +import com.cake.pop.entity.enums.Region; +import com.cake.pop.entity.enums.Status; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class LetterService { + + private final LetterRepository letterRepository; + private final MailboxRepository mailboxRepository; + private final StorageRepository storageRepository; + private final UserRepository userRepository; + + @Transactional + public void createLetter(CreateLetterRequest request) { + Mailbox findMailbox = mailboxRepository.getFirstByRegion(request.region()); + findMailbox.increaseLetterCount(); + + Letter letter = Letter.of(request.content(), findMailbox, request.imageUrl()); + letterRepository.save(letter); + } + + public GetLettersResponse getLetters(String region){ + Mailbox findMailbox = mailboxRepository.getFirstByRegion(Region.fromKoreanName(region)); + + List letters = letterRepository.findByMailboxAndStatus(findMailbox, Status.ACTIVE); + + List letterDtos = letters.stream() + .map(letter -> new SimpleLetterDto( + letter.getId(), + letter.getContent(), + letter.getCreatedAt().toLocalDate() + )) + .toList(); + + return new GetLettersResponse(region, letterDtos); + } + + public GetLetterResponse getLetter(Long letterId){ + Letter findLetter = letterRepository.getByIdWithMailbox(letterId); + + return new GetLetterResponse( + findLetter.getId(), + findLetter.getContent(), + findLetter.getImageUrl(), + findLetter.getMailbox().getRegion().getName() + ); + } + + @Transactional + public void createStorage(Long userId, CreateStorageRequest request){ + User findUser = userRepository.getById(userId); + Letter findLetter = letterRepository.getById(request.letterId()); + Storage storage = Storage.of(findUser, findLetter); + + storageRepository.save(storage); + } + + public List getStorages(Long userId){ + User findUser = userRepository.getById(userId); + List storages = storageRepository.findByUser(findUser); + + return storages.stream() + .map(storage -> { + Letter letter = storage.getLetter(); + return new SimpleLetterDto( + letter.getId(), + letter.getContent(), + letter.getCreatedAt().toLocalDate() // LocalDateTime โ†’ LocalDate + ); + }) + .toList(); + } + + @Transactional + public void reportLetter(Long letterId){ + Letter findLetter = letterRepository.getById(letterId); + findLetter.report(); + } +} diff --git a/src/main/java/com/cake/pop/domain/letter/service/MailboxService.java b/src/main/java/com/cake/pop/domain/letter/service/MailboxService.java new file mode 100644 index 0000000..b779062 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/letter/service/MailboxService.java @@ -0,0 +1,28 @@ +package com.cake.pop.domain.letter.service; + +import com.cake.pop.domain.letter.dto.response.GetRankResponse; +import com.cake.pop.domain.letter.repository.MailboxRepository; +import com.cake.pop.entity.Mailbox; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class MailboxService { + + private final MailboxRepository mailboxRepository; + + public List getRanks() { + List topMailboxes = mailboxRepository.findTop3ByOrderByLetterCountDesc(); + + return topMailboxes.stream() + .map(mailbox -> new GetRankResponse( + mailbox.getRegion().getName(), + mailbox.getLetterCount() + )) + .toList(); + } +} diff --git a/src/main/java/com/cake/pop/domain/user/controller/UserController.java b/src/main/java/com/cake/pop/domain/user/controller/UserController.java new file mode 100644 index 0000000..f1e426a --- /dev/null +++ b/src/main/java/com/cake/pop/domain/user/controller/UserController.java @@ -0,0 +1,13 @@ +package com.cake.pop.domain.user.controller; + +import com.cake.pop.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + private final UserService userService; +} diff --git a/src/main/java/com/cake/pop/domain/user/exception/UserErrorCode.java b/src/main/java/com/cake/pop/domain/user/exception/UserErrorCode.java new file mode 100644 index 0000000..76a86c8 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/user/exception/UserErrorCode.java @@ -0,0 +1,17 @@ +package com.cake.pop.domain.user.exception; + +import com.cake.pop.global.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cake/pop/domain/user/repository/UserRepository.java b/src/main/java/com/cake/pop/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..f8166e3 --- /dev/null +++ b/src/main/java/com/cake/pop/domain/user/repository/UserRepository.java @@ -0,0 +1,19 @@ +package com.cake.pop.domain.user.repository; + +import java.util.Optional; + +import com.cake.pop.domain.user.exception.UserErrorCode; +import com.cake.pop.entity.User; +import com.cake.pop.global.exception.RestApiException; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { + + default User getById(Long id){ + return findById(id).orElseThrow(()->new RestApiException(UserErrorCode.USER_NOT_FOUND)); + } + + Optional findFirstByEmail(String email); +} diff --git a/src/main/java/com/cake/pop/domain/user/service/UserService.java b/src/main/java/com/cake/pop/domain/user/service/UserService.java new file mode 100644 index 0000000..59a87ef --- /dev/null +++ b/src/main/java/com/cake/pop/domain/user/service/UserService.java @@ -0,0 +1,34 @@ +package com.cake.pop.domain.user.service; + +import com.cake.pop.domain.user.repository.UserRepository; +import com.cake.pop.entity.User; +import com.cake.pop.global.auth.oauth.Oauth2Response; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + public User saveOrUpdate(Oauth2Response oauth2Response) { + User user = userRepository.findFirstByEmail(oauth2Response.getEmail()) + .map(u -> { + u.updateEmail(oauth2Response.getEmail()); + // deleteRefreshTokenIfExists(m); + // deleteOauthAccessTokenIfExists(m); + // saveOauth2AccessToken(oauth2Response, m); + return u; + }) + .orElseGet(() -> createMemberFromOauth2Response(oauth2Response)); + + return userRepository.save(user); + } + + private User createMemberFromOauth2Response(Oauth2Response oauth2Response) { + User user = User.of(oauth2Response.getEmail()); + // saveOauth2AccessToken(oauth2Response, member); + return user; + } +} diff --git a/src/main/java/com/cake/pop/entity/Letter.java b/src/main/java/com/cake/pop/entity/Letter.java new file mode 100644 index 0000000..8fb505a --- /dev/null +++ b/src/main/java/com/cake/pop/entity/Letter.java @@ -0,0 +1,72 @@ +package com.cake.pop.entity; + +import com.cake.pop.entity.enums.Status; +import com.cake.pop.global.domain.TimeBaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "letter") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Letter extends TimeBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "letter_id") + private Long id; + + @Column(name = "content", length = 350, nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; + + @Column(name = "report", nullable = false) + private Integer report; + + @Column(name = "image_url") + private String imageUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mailbox_id", nullable = false) + Mailbox mailbox; + + @Builder(access = AccessLevel.PRIVATE) + private Letter(String content, Mailbox mailbox, String imageUrl) { + this.content = content; + this.status = Status.ACTIVE; + this.mailbox = mailbox; + this.imageUrl = imageUrl; + this.report = 0; + } + + public static Letter of(String content, Mailbox mailbox, String imageUrl) { + return Letter.builder() + .content(content) + .mailbox(mailbox) + .imageUrl(imageUrl) + .build(); + } + + public void report() { + this.report++; + if (this.report >= 3) { + this.status = Status.INACTIVE; + } + } +} diff --git a/src/main/java/com/cake/pop/entity/Mailbox.java b/src/main/java/com/cake/pop/entity/Mailbox.java new file mode 100644 index 0000000..7d9e3d2 --- /dev/null +++ b/src/main/java/com/cake/pop/entity/Mailbox.java @@ -0,0 +1,51 @@ +package com.cake.pop.entity; + +import com.cake.pop.entity.enums.Region; +import com.cake.pop.entity.enums.Status; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mailbox") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Mailbox { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "mailbox_id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "region", nullable = false) + private Region region; + + @Column(name = "letter_count") + private Integer letterCount; + + @Builder(access = AccessLevel.PRIVATE) + private Mailbox(Region region) { + this.region = region; + this.letterCount = 0; + } + + public static Mailbox of(Region region) { + return Mailbox.builder() + .region(region) + .build(); + } + + public void increaseLetterCount() { + this.letterCount = this.letterCount + 1; + } +} diff --git a/src/main/java/com/cake/pop/entity/Storage.java b/src/main/java/com/cake/pop/entity/Storage.java new file mode 100644 index 0000000..6ce8d27 --- /dev/null +++ b/src/main/java/com/cake/pop/entity/Storage.java @@ -0,0 +1,48 @@ +package com.cake.pop.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "storage") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Storage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "storage_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "letter_id", nullable = false) + Letter letter; + + @Builder(access = AccessLevel.PRIVATE) + private Storage(User user, Letter letter) { + this.user = user; + this.letter = letter; + } + + public static Storage of(User user, Letter letter) { + return Storage.builder() + .user(user) + .letter(letter) + .build(); + } +} diff --git a/src/main/java/com/cake/pop/entity/User.java b/src/main/java/com/cake/pop/entity/User.java new file mode 100644 index 0000000..256ec2a --- /dev/null +++ b/src/main/java/com/cake/pop/entity/User.java @@ -0,0 +1,43 @@ +package com.cake.pop.entity; + +import com.cake.pop.global.domain.TimeBaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends TimeBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(name = "email", nullable = false) + private String email; + + @Builder(access = AccessLevel.PRIVATE) + private User(String email){ + this.email = email; + } + + public static User of(String email){ + return User.builder() + .email(email) + .build(); + } + + public void updateEmail(String email) { + this.email = email; + } +} diff --git a/src/main/java/com/cake/pop/entity/enums/Region.java b/src/main/java/com/cake/pop/entity/enums/Region.java new file mode 100644 index 0000000..c7c875d --- /dev/null +++ b/src/main/java/com/cake/pop/entity/enums/Region.java @@ -0,0 +1,55 @@ +package com.cake.pop.entity.enums; + +import com.cake.pop.global.exception.CommonErrorCode; +import com.cake.pop.global.exception.RestApiException; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonCreator.Mode; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Region { + GANGNAM("๊ฐ•๋‚จ๊ตฌ"), + GANGDONG("๊ฐ•๋™๊ตฌ"), + GANGBUK("๊ฐ•๋ถ๊ตฌ"), + GANGSEO("๊ฐ•์„œ๊ตฌ"), + GWANAK("๊ด€์•…๊ตฌ"), + GWANGJIN("๊ด‘์ง„๊ตฌ"), + GURO("๊ตฌ๋กœ๊ตฌ"), + GEUMCHEON("๊ธˆ์ฒœ๊ตฌ"), + NOWON("๋…ธ์›๊ตฌ"), + DOBONG("๋„๋ด‰๊ตฌ"), + DONGDAEMUN("๋™๋Œ€๋ฌธ๊ตฌ"), + DONGJAK("๋™์ž‘๊ตฌ"), + MAPO("๋งˆํฌ๊ตฌ"), + SEODAEMUN("์„œ๋Œ€๋ฌธ๊ตฌ"), + SEOCHO("์„œ์ดˆ๊ตฌ"), + SEONGDONG("์„ฑ๋™๊ตฌ"), + SEONGBUK("์„ฑ๋ถ๊ตฌ"), + SONGPA("์†กํŒŒ๊ตฌ"), + YANGCHEON("์–‘์ฒœ๊ตฌ"), + YEONGDEUNGPO("์˜๋“ฑํฌ๊ตฌ"), + YONGSAN("์šฉ์‚ฐ๊ตฌ"), + EUNPYEONG("์€ํ‰๊ตฌ"), + JONGNO("์ข…๋กœ๊ตฌ"), + JUNG("์ค‘๊ตฌ"), + JUNGRANG("์ค‘๋ž‘๊ตฌ"); + + @JsonCreator(mode = Mode.DELEGATING) + public static Region fromKoreanName(String name) { + return Arrays.stream(Region.values()) + .filter(region -> region.getName().equals(name)) + .findFirst() + .orElseThrow(() -> new RestApiException(CommonErrorCode.REGION_NOT_FOUND)); + } + + @JsonValue + public String toKoreanName() { + return name; + } + + private final String name; +} diff --git a/src/main/java/com/cake/pop/entity/enums/Status.java b/src/main/java/com/cake/pop/entity/enums/Status.java new file mode 100644 index 0000000..cdf1de0 --- /dev/null +++ b/src/main/java/com/cake/pop/entity/enums/Status.java @@ -0,0 +1,5 @@ +package com.cake.pop.entity.enums; + +public enum Status { + ACTIVE, INACTIVE +} diff --git a/src/main/java/com/cake/pop/global/auth/handler/CustomAccessDeniedHandler.java b/src/main/java/com/cake/pop/global/auth/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..683f887 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,81 @@ +package com.cake.pop.global.auth.handler; + +import java.io.IOException; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.cake.pop.global.exception.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private static final String ROLE_GUEST = "ROLE_GUEST"; + private static final String ROLE_USER = "ROLE_USER"; + + private static boolean matchAuthenticationFromRole(Authentication authentication, String role) { + String authRole = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining()); + + return Objects.equals(authRole, role); + } + + private static void setUpResponse( + HttpServletResponse response, + SecurityErrorCode securityErrorCode + ) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse( + securityErrorCode.getMessage(), + securityErrorCode.getHttpStatus().name() + ); + + ObjectMapper mapper = new ObjectMapper(); + String jsonResponse = mapper.writeValueAsString(errorResponse); + + response.getWriter().write(jsonResponse); + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + // ํ˜„์žฌ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + log.error(authentication.getName()); + // ์‚ฌ์šฉ์ž ๊ถŒํ•œ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์‘๋‹ต ์ œ๊ณต + if (!Objects.isNull(accessDeniedException)) { + if (!matchAuthenticationFromRole(authentication, ROLE_USER)) { + // ROLE_USER ๊ถŒํ•œ์ด ์—†๋Š” ๊ฒฝ์šฐ + log.info(SecurityErrorCode.FORBIDDEN_USER.getMessage()); + setUpResponse(response, SecurityErrorCode.FORBIDDEN_USER); + } else if (!matchAuthenticationFromRole(authentication, ROLE_GUEST)) { + // ROLE_GUEST ๊ถŒํ•œ์ด ์—†๋Š” ๊ฒฝ์šฐ + log.info(SecurityErrorCode.FORBIDDEN_GUEST.getMessage()); + setUpResponse(response, SecurityErrorCode.FORBIDDEN_GUEST); + } else { + // ๊ธฐํƒ€ ๊ถŒํ•œ์ด ์—†๋Š” ๊ฒฝ์šฐ + log.info(SecurityErrorCode.FORBIDDEN_MISMATCH.getMessage()); + setUpResponse(response, SecurityErrorCode.FORBIDDEN_MISMATCH); + } + } else { + log.info(SecurityErrorCode.FORBIDDEN_MISMATCH.getMessage()); + setUpResponse(response, SecurityErrorCode.FORBIDDEN_MISMATCH); + } + } +} diff --git a/src/main/java/com/cake/pop/global/auth/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/cake/pop/global/auth/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..82c02d3 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,35 @@ +package com.cake.pop.global.auth.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import com.cake.pop.global.exception.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + + log.error("๋น„์ธ๊ฐ€ ์‚ฌ์šฉ์ž ์š”์ฒญ -> ์˜ˆ์™ธ ๋ฐœ์ƒ : {}", authException.getMessage()); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse(SecurityErrorCode.UNAUTHORIZED_USER.getMessage(), + SecurityErrorCode.UNAUTHORIZED_USER.getMessage()); + + ObjectMapper mapper = new ObjectMapper(); + String jsonResponse = mapper.writeValueAsString(errorResponse); + + response.getWriter().write(jsonResponse); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2FailureHandler.java b/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2FailureHandler.java new file mode 100644 index 0000000..5568edd --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2FailureHandler.java @@ -0,0 +1,23 @@ +package com.cake.pop.global.auth.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CustomOauth2FailureHandler implements AuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException { + log.error("์†Œ์…œ ๋กœ๊ทธ์ธ ์‹คํŒจ", exception); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "์†Œ์…œ ๋กœ๊ทธ์ธ์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค."); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2SuccessHandler.java b/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2SuccessHandler.java new file mode 100644 index 0000000..c6dcde4 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2SuccessHandler.java @@ -0,0 +1,54 @@ +package com.cake.pop.global.auth.handler; + +import java.io.IOException; +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.cake.pop.domain.user.exception.UserErrorCode; +import com.cake.pop.domain.user.repository.UserRepository; +import com.cake.pop.entity.User; +import com.cake.pop.global.auth.jwt.CookieUtil; +import com.cake.pop.global.auth.jwt.TokenProvider; +import com.cake.pop.global.auth.oauth.CustomOauth2User; +import com.cake.pop.global.exception.RestApiException; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CustomOauth2SuccessHandler implements AuthenticationSuccessHandler { + + private final UserRepository userRepository; + private final TokenProvider tokenProvider; + private final CookieUtil cookieUtil; + @Value("${direct.home}") + private String REDIRECTION_HOME; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + CustomOauth2User customOauth2User = (CustomOauth2User)authentication.getPrincipal(); + + String email = customOauth2User.getEmail(); + User findUser = userRepository.findFirstByEmail(email) + .orElseThrow(() -> new RestApiException(UserErrorCode.USER_NOT_FOUND)); + + String token = tokenProvider.generateAccessToken(findUser, customOauth2User, new Date()); + // tokenProvider.generateRefreshToken(findUser, customOauth2User, new Date()); + + response.addCookie(cookieUtil.createCookie(token)); + + response.sendRedirect(REDIRECTION_HOME); + } + + private boolean isRoleGuest(String role) { + return "ROLE_GUEST".equals(role); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/handler/SecurityErrorCode.java b/src/main/java/com/cake/pop/global/auth/handler/SecurityErrorCode.java new file mode 100644 index 0000000..c31c9ce --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/SecurityErrorCode.java @@ -0,0 +1,20 @@ +package com.cake.pop.global.auth.handler; + + +import org.springframework.http.HttpStatus; + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SecurityErrorCode implements ErrorCode { + UNAUTHORIZED_USER(HttpStatus.NOT_FOUND, "๋น„์ธ๊ฐ€ ์‚ฌ์šฉ์ž ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + FORBIDDEN_USER(HttpStatus.NOT_FOUND, "ROLE_USER ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), + FORBIDDEN_GUEST(HttpStatus.NOT_FOUND, "ROLE_GUEST ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), + FORBIDDEN_MISMATCH(HttpStatus.NOT_FOUND, "์–ด๋–ค ๊ถŒํ•œ๋„ ๋งค์น˜๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/CookieUtil.java b/src/main/java/com/cake/pop/global/auth/jwt/CookieUtil.java new file mode 100644 index 0000000..ebd367e --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/CookieUtil.java @@ -0,0 +1,50 @@ +package com.cake.pop.global.auth.jwt; + +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CookieUtil { + + public Cookie createCookie(String token) { + Cookie cookie = new Cookie("Authorization", token); + cookie.setPath("/"); + cookie.setDomain("cake-pop.shop"); + cookie.setMaxAge(60 * 180); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setAttribute("SameSite", "Strict"); + return cookie; + } + + public void deleteCookie(HttpServletResponse response) { + Cookie cookie = new Cookie("Authorization", null); + cookie.setPath("/"); + cookie.setMaxAge(0); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setAttribute("SameSite", "Strict"); + + response.addCookie(cookie); + } + + + public String getCookieValue(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("Authorization".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/CustomJwtException.java b/src/main/java/com/cake/pop/global/auth/jwt/CustomJwtException.java new file mode 100644 index 0000000..a46e21b --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/CustomJwtException.java @@ -0,0 +1,17 @@ +package com.cake.pop.global.auth.jwt; + + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; + +@Getter +public class CustomJwtException extends RuntimeException { + + private final String code; + + public CustomJwtException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getHttpStatus().name(); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/JwtErrorCode.java b/src/main/java/com/cake/pop/global/auth/jwt/JwtErrorCode.java new file mode 100644 index 0000000..a394719 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/JwtErrorCode.java @@ -0,0 +1,21 @@ +package com.cake.pop.global.auth.jwt; + +import org.springframework.http.HttpStatus; + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum JwtErrorCode implements ErrorCode { + + MALFORMED_TOKEN(HttpStatus.NOT_FOUND, "์•Œ๋งž์ง€ ์•Š์€ ํ˜•์‹์˜ ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + INVALID_TOKEN(HttpStatus.NOT_FOUND, "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + EXPIRED_TOKEN(HttpStatus.NOT_FOUND, "๋งŒ๋ฃŒ๋œ ํ† ํฐ์ž…๋‹ˆ๋‹ค."); + + private final HttpStatus httpStatus; + private final String message; + +} \ No newline at end of file diff --git a/src/main/java/com/cake/pop/global/auth/jwt/TokenAuthenticationFilter.java b/src/main/java/com/cake/pop/global/auth/jwt/TokenAuthenticationFilter.java new file mode 100644 index 0000000..17d4a30 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/TokenAuthenticationFilter.java @@ -0,0 +1,74 @@ +package com.cake.pop.global.auth.jwt; + +import static org.springframework.http.HttpHeaders.*; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.ObjectUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Component +@Slf4j +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private static final String TOKEN_PREFIX = "Bearer "; + private static final List SKIP_URLS = Arrays.asList( + "/error", "/favicon.ico", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html", "/ws/**", + "/api/auth/temp-signup", "/api/auth/temp-signin", "/api/auth/reissue/token" + ); + private static final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final TokenProvider tokenProvider; + private final CookieUtil cookieUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + for (String pattern : SKIP_URLS) { + if (pathMatcher.match(pattern, request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + } + + String accessToken = cookieUtil.getCookieValue(request); + + if (tokenProvider.validateToken(accessToken, new Date())) { + // accessToken logout ์—ฌ๋ถ€ ํ™•์ธ + // if (tokenProvider.verifyBlackList(accessToken)) { + saveAuthentication(accessToken); + // } + } + + filterChain.doFilter(request, response); + } + + private void saveAuthentication(String accessToken) { + Authentication authentication = tokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private String resolveToken(HttpServletRequest request) { + String token = request.getHeader(AUTHORIZATION); + if (ObjectUtils.isEmpty(token) || !token.startsWith(TOKEN_PREFIX)) { + return null; + } + return token.substring(TOKEN_PREFIX.length()); + } + +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/TokenExceptionFilter.java b/src/main/java/com/cake/pop/global/auth/jwt/TokenExceptionFilter.java new file mode 100644 index 0000000..2f93e2f --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/TokenExceptionFilter.java @@ -0,0 +1,40 @@ +package com.cake.pop.global.auth.jwt; + +import java.io.IOException; + +import org.springframework.web.filter.OncePerRequestFilter; + +import com.cake.pop.global.exception.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TokenExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (CustomJwtException e) { + + log.error("JWT ๊ฒ€์ฆ ์‹คํŒจ๋กœ ์ธํ•œ ์˜ˆ์™ธ ๋ฐœ์ƒ : {}", e.getMessage()); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse(e.getMessage(), e.getCode()); + + ObjectMapper mapper = new ObjectMapper(); + String jsonResponse = mapper.writeValueAsString(errorResponse); + + response.getWriter().write(jsonResponse); + } + } +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/TokenProvider.java b/src/main/java/com/cake/pop/global/auth/jwt/TokenProvider.java new file mode 100644 index 0000000..115a4d2 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/TokenProvider.java @@ -0,0 +1,154 @@ +package com.cake.pop.global.auth.jwt; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.cake.pop.domain.user.exception.UserErrorCode; +import com.cake.pop.domain.user.repository.UserRepository; +import com.cake.pop.entity.User; +import com.cake.pop.global.auth.oauth.CustomOauth2User; +import com.cake.pop.global.exception.RestApiException; +import com.cake.pop.global.redis.util.RedisUtil; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TokenProvider { + + private static final String ROLE_KEY = "ROLE"; + private static final String[] BLACKLIST = new String[] {"false", "delete"}; + private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 90L; + private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24L; + private final UserRepository userRepository; + private final RedisUtil redisUtil; + @Value("${spring.jwt.key}") + private String key; + private SecretKey secretKey; + + @PostConstruct + private void initSecretKey() { + this.secretKey = Keys.hmacShaKeyFor(key.getBytes()); + } + + public String generateAccessToken(User findUser, CustomOauth2User authentication, Date now) { + return generateToken(findUser, authentication, ACCESS_TOKEN_EXPIRE_TIME, now); + } + + public void generateRefreshToken(User findUser, CustomOauth2User authentication, Date now) { + String refreshToken = generateToken(findUser, authentication, REFRESH_TOKEN_EXPIRE_TIME, now); + + // redis Refresh ์ €์žฅ + // redisUtil.setValues("RT:" + authentication.getEmail(), refreshToken, + // Duration.ofMillis(REFRESH_TOKEN_EXPIRE_TIME)); + } + + private String generateToken(User findUser, CustomOauth2User authentication, long tokenExpireTime, Date now) { + Date expiredTime = createExpiredDateWithTokenType(now, tokenExpireTime); + String authorities = getAuthorities(authentication); + + return Jwts.builder() + .subject(String.valueOf(findUser.getId())) + .claim(ROLE_KEY, authorities) + .issuedAt(now) + .expiration(expiredTime) + .signWith(secretKey, Jwts.SIG.HS512) + .compact(); + } + + private String getAuthorities(CustomOauth2User authentication) { + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining()); + } + + private Date createExpiredDateWithTokenType(Date date, long tokenExpireTime) { + return new Date(date.getTime() + tokenExpireTime); + } + + public Authentication getAuthentication(String token) { + Claims claims = parseToken(token); + List authorities = getAuthorities(claims); + + String subject = claims.getSubject(); + User principal = userRepository.findById(Long.valueOf(subject)) + .orElseThrow(() -> new RestApiException(UserErrorCode.USER_NOT_FOUND)); + + return new UsernamePasswordAuthenticationToken(principal.getId(), token, authorities); + } + + public boolean validateToken(String token, Date date) { + if (!StringUtils.hasText(token)) { + return false; + } + + Claims claims = parseToken(token); + if (!claims.getExpiration().after(date)) { + throw new CustomJwtException(JwtErrorCode.EXPIRED_TOKEN); + } + + return true; + } + + private Claims parseToken(String token) { + try { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token).getPayload(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } catch (MalformedJwtException e) { + throw new CustomJwtException(JwtErrorCode.MALFORMED_TOKEN); + } catch (JwtException e) { + throw new CustomJwtException(JwtErrorCode.INVALID_TOKEN); + } catch (Exception e) { + throw new CustomJwtException(JwtErrorCode.INVALID_TOKEN); + } + } + + private List getAuthorities(Claims claims) { + return Collections.singletonList(new SimpleGrantedAuthority( + claims.get(ROLE_KEY).toString() + )); + } + + public Long getExpiration(String token, Date date) { + Claims claims = parseToken(token); + Date expiration = claims.getExpiration(); + return (expiration.getTime() - date.getTime()); + } + + public boolean verifyBlackList(String accessToken) { + String value = redisUtil.getValues(accessToken); + return Arrays.asList(BLACKLIST).contains(value); + } + + public User getMemberAllowExpired(String token) { + Claims claims = parseToken(token); + + String subject = claims.getSubject(); + return userRepository.findById(Long.valueOf(subject)) + .orElseThrow(() -> new RestApiException(UserErrorCode.USER_NOT_FOUND)); + } + +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/AuthErrorCode.java b/src/main/java/com/cake/pop/global/auth/oauth/AuthErrorCode.java new file mode 100644 index 0000000..240e06f --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/AuthErrorCode.java @@ -0,0 +1,23 @@ +package com.cake.pop.global.auth.oauth; + +import org.springframework.http.HttpStatus; + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + UNSUPPORTED_SOCIAL_LOGIN(HttpStatus.NOT_FOUND, "ํ•ด๋‹น ์†Œ์…œ ๋กœ๊ทธ์ธ์€ ์ง€์›๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + NOT_FOUND_PROVIDER(HttpStatus.NOT_FOUND, "์•Œ๋งž์€ Provider๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + NOT_FOUND_AUTH(HttpStatus.NOT_FOUND, "ํšŒ์›์˜ AUTH๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + UNAUTHORIZED_TOKEN(HttpStatus.NOT_FOUND, "์ž˜๋ชป๋œ ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + FAIL_REISSUE_TOKEN(HttpStatus.NOT_FOUND, "ํ† ํฐ ์žฌ๋ฐœ๊ธ‰์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + MISSING_ACCESS_TOKEN(HttpStatus.NOT_FOUND, "AT๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/AuthInfo.java b/src/main/java/com/cake/pop/global/auth/oauth/AuthInfo.java new file mode 100644 index 0000000..848a6d4 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/AuthInfo.java @@ -0,0 +1,23 @@ +package com.cake.pop.global.auth.oauth; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AuthInfo { + + private String email; + + @Builder + private AuthInfo(String email) { + this.email = email; + } + + public static AuthInfo of(String email) { + return AuthInfo.builder() + .email(email) + .build(); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2User.java b/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2User.java new file mode 100644 index 0000000..477b95b --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2User.java @@ -0,0 +1,38 @@ +package com.cake.pop.global.auth.oauth; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +public class CustomOauth2User implements OAuth2User { + + private final AuthInfo authInfo; + private Map attributes; + + public CustomOauth2User(AuthInfo authInfo) { + this.authInfo = authInfo; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority("USER")); + } + + @Override + public String getName() { + return authInfo.getEmail(); + } + + public String getEmail() { + return authInfo.getEmail(); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2UserService.java b/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2UserService.java new file mode 100644 index 0000000..d4bfc0b --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2UserService.java @@ -0,0 +1,57 @@ +package com.cake.pop.global.auth.oauth; + + + +import static com.cake.pop.global.auth.oauth.AuthErrorCode.*; +import static com.cake.pop.global.auth.oauth.Provider.*; + +import java.util.Objects; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.cake.pop.domain.user.service.UserService; +import com.cake.pop.entity.User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomOauth2UserService extends DefaultOAuth2UserService { + + private final UserService userService; + + @Transactional + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + String oauth2AccessToken = userRequest.getAccessToken().getTokenValue(); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + Oauth2Response oauth2Response = null; + + if (Objects.equals(registrationId, KAKAO.getLabel())) { + oauth2Response = new KakaoResponse(oAuth2User.getAttributes(), oauth2AccessToken); + } else { + throw new OAuth2AuthenticationException( + new OAuth2Error(AuthErrorCode.UNSUPPORTED_SOCIAL_LOGIN.getMessage()), + AuthErrorCode.UNSUPPORTED_SOCIAL_LOGIN.getMessage() + ); + } + + User savedUser = userService.saveOrUpdate(oauth2Response); + + AuthInfo authInfo = AuthInfo.of( + savedUser.getEmail() + ); + return new CustomOauth2User(authInfo); + } + +} + diff --git a/src/main/java/com/cake/pop/global/auth/oauth/KakaoResponse.java b/src/main/java/com/cake/pop/global/auth/oauth/KakaoResponse.java new file mode 100644 index 0000000..e5d720d --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/KakaoResponse.java @@ -0,0 +1,51 @@ +package com.cake.pop.global.auth.oauth; + +import java.util.Map; + +public class KakaoResponse implements Oauth2Response { + + private final Map attribute; + private final Long id; + private final String oauth2AccessToken; + + public KakaoResponse(Map attribute, String oauth2AccessToken) { + this.attribute = (Map)attribute.get("kakao_account"); + this.id = (Long)attribute.get("id"); + this.oauth2AccessToken = oauth2AccessToken; + } + + @Override + public String getProvider() { + return Provider.KAKAO.getLabel(); + } + + @Override + public String getProviderId() { + return this.id.toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + @Override + public String getName() { + return ((Map)attribute.get("profile")).get("nickname").toString(); + } + + @Override + public String createSocialEmail() { + return String.format("%s%s/%s", + this.getProvider(), + this.getProviderId(), + this.getEmail() + ); + } + + @Override + public String getOauth2AccessToken() { + return this.oauth2AccessToken; + } + +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/Oauth2Response.java b/src/main/java/com/cake/pop/global/auth/oauth/Oauth2Response.java new file mode 100644 index 0000000..952e398 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/Oauth2Response.java @@ -0,0 +1,15 @@ +package com.cake.pop.global.auth.oauth; + +public interface Oauth2Response { + String getProvider(); + + String getProviderId(); + + String getEmail(); + + String getName(); + + String createSocialEmail(); + + String getOauth2AccessToken(); +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/Provider.java b/src/main/java/com/cake/pop/global/auth/oauth/Provider.java new file mode 100644 index 0000000..cebc13b --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/Provider.java @@ -0,0 +1,32 @@ +package com.cake.pop.global.auth.oauth; + +import java.util.Arrays; + +import com.cake.pop.global.exception.RestApiException; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Provider { + + KAKAO("kakao"), + NAVER("naver"); + + private final String label; + + public static Provider fromProviderName(String providerName) { + return Arrays.stream(values()) + .filter(provider -> provider.getLabel().equalsIgnoreCase(providerName)) + .findFirst() + .orElseThrow(() -> new RestApiException(AuthErrorCode.NOT_FOUND_PROVIDER)); + } + + public static Provider fromSocialEmail(String socialEmail) { + return Arrays.stream(values()) + .filter(provider -> socialEmail.contains(provider.getLabel())) + .findFirst() + .orElseThrow(() -> new RestApiException(AuthErrorCode.NOT_FOUND_PROVIDER)); + } +} diff --git a/src/main/java/com/cake/pop/global/config/MailboxInitializer.java b/src/main/java/com/cake/pop/global/config/MailboxInitializer.java new file mode 100644 index 0000000..36a7e82 --- /dev/null +++ b/src/main/java/com/cake/pop/global/config/MailboxInitializer.java @@ -0,0 +1,26 @@ +package com.cake.pop.global.config; + +import com.cake.pop.domain.letter.repository.MailboxRepository; +import com.cake.pop.entity.Mailbox; +import com.cake.pop.entity.enums.Region; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MailboxInitializer implements ApplicationRunner { + + private final MailboxRepository mailboxRepository; + + @Override + public void run(ApplicationArguments args) throws Exception { + if(mailboxRepository.count() == 0){ + for (Region region : Region.values()) { + Mailbox mailbox = Mailbox.of(region); + mailboxRepository.save(mailbox); + } + } + } +} diff --git a/src/main/java/com/cake/pop/global/config/RedisConfig.java b/src/main/java/com/cake/pop/global/config/RedisConfig.java new file mode 100644 index 0000000..0ca9245 --- /dev/null +++ b/src/main/java/com/cake/pop/global/config/RedisConfig.java @@ -0,0 +1,44 @@ +package com.cake.pop.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password:}") + private String password; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(host); + config.setPort(port); + if (!password.isEmpty()) { + config.setPassword(password); + } + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/com/cake/pop/global/config/SecurityConfig.java b/src/main/java/com/cake/pop/global/config/SecurityConfig.java new file mode 100644 index 0000000..fe6b181 --- /dev/null +++ b/src/main/java/com/cake/pop/global/config/SecurityConfig.java @@ -0,0 +1,107 @@ +package com.cake.pop.global.config; + +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import com.cake.pop.global.auth.handler.CustomAccessDeniedHandler; +import com.cake.pop.global.auth.handler.CustomAuthenticationEntryPoint; +import com.cake.pop.global.auth.handler.CustomOauth2FailureHandler; +import com.cake.pop.global.auth.handler.CustomOauth2SuccessHandler; +import com.cake.pop.global.auth.jwt.TokenAuthenticationFilter; +import com.cake.pop.global.auth.jwt.TokenExceptionFilter; +import com.cake.pop.global.auth.oauth.CustomOauth2UserService; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomOauth2UserService customOauth2UserService; + private final CustomOauth2SuccessHandler customOauth2SuccessHandler; + private final CustomOauth2FailureHandler customOauth2FailureHandler; + private final TokenAuthenticationFilter tokenAuthenticationFilter; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf((auth) -> auth.disable()) + .formLogin((auth) -> auth.disable()) + .httpBasic((auth) -> auth.disable()) + .sessionManagement( + (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + + http + .authorizeHttpRequests( + (auth) -> auth + .requestMatchers( + "/oauth2/authorization/**", // ๋กœ๊ทธ์ธ ์‹œ์ž‘ ์‹œ + "/login/oauth2/**", // ์ฝœ๋ฐฑ ์‹œ + "/error" // ์—๋Ÿฌ ํŽ˜์ด์ง€ + ).permitAll() + .requestMatchers("/").permitAll() + .requestMatchers("/api/auth/reissue/token").permitAll() + ) + .oauth2Login((oauth2) -> oauth2 + .userInfoEndpoint( + (userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOauth2UserService)) + ) + .successHandler(customOauth2SuccessHandler) + .failureHandler(customOauth2FailureHandler) + ) + + .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new TokenExceptionFilter(), tokenAuthenticationFilter.getClass()) + + .exceptionHandling((exception) -> exception + .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) + .accessDeniedHandler(customAccessDeniedHandler) + ); + + return http.build(); + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring() + .requestMatchers( + "/error", "/favicon.ico", "/api/auth/temp-signup", "/api/auth/temp-signin", + "/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html", "/ws/**" + ); + } + + // Spring Security cors Bean ๋“ฑ๋ก + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins( + Arrays.asList( + "http://localhost:3000", "https://cake-pop.vercel.app", + "https://www.cake-pop.shop", "https://cake-pop.shop", "http://localhost:8080", + "https://dev.cake-pop.shop" + )); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3000L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/com/cake/pop/global/config/SwaggerConfig.java b/src/main/java/com/cake/pop/global/config/SwaggerConfig.java new file mode 100644 index 0000000..4f80e25 --- /dev/null +++ b/src/main/java/com/cake/pop/global/config/SwaggerConfig.java @@ -0,0 +1,51 @@ +package com.cake.pop.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.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; + + +@Configuration +public class SwaggerConfig { + + private static final String ACCESS_TOKEN_KEY = "Access Token (Bearer)"; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(createComponents()) + .addSecurityItem(createSecurityRequirement()) + .info(createApiInfo()); + } + + private Components createComponents() { + return new Components() + .addSecuritySchemes(ACCESS_TOKEN_KEY, createAccessTokenSecurityScheme()); + } + + private SecurityRequirement createSecurityRequirement() { + return new SecurityRequirement() + .addList(ACCESS_TOKEN_KEY); + } + + private SecurityScheme createAccessTokenSecurityScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("Authorization") + .in(SecurityScheme.In.HEADER) + .name(HttpHeaders.AUTHORIZATION); + } + + private Info createApiInfo() { + return new Info() + .title("Pop API") + .description("Pop API ๋ช…์„ธ์„œ") + .version("1.0.0"); + } +} diff --git a/src/main/java/com/cake/pop/global/domain/TimeBaseEntity.java b/src/main/java/com/cake/pop/global/domain/TimeBaseEntity.java new file mode 100644 index 0000000..cb49153 --- /dev/null +++ b/src/main/java/com/cake/pop/global/domain/TimeBaseEntity.java @@ -0,0 +1,20 @@ +package com.cake.pop.global.domain; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class TimeBaseEntity { + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/cake/pop/global/exception/CommonErrorCode.java b/src/main/java/com/cake/pop/global/exception/CommonErrorCode.java new file mode 100644 index 0000000..b164986 --- /dev/null +++ b/src/main/java/com/cake/pop/global/exception/CommonErrorCode.java @@ -0,0 +1,22 @@ +package com.cake.pop.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +@Getter +public enum CommonErrorCode implements ErrorCode { + INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid parameter included"), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not exists"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid token"), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "Expired token"), + NOT_EXIST_BEARER_SUFFIX(HttpStatus.UNAUTHORIZED, "Bearer prefix is missing."), + REFRESH_DENIED(HttpStatus.FORBIDDEN, "Refresh denied"), + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "๊ตฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + ; + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/cake/pop/global/exception/ErrorCode.java b/src/main/java/com/cake/pop/global/exception/ErrorCode.java new file mode 100644 index 0000000..fce3d12 --- /dev/null +++ b/src/main/java/com/cake/pop/global/exception/ErrorCode.java @@ -0,0 +1,13 @@ +package com.cake.pop.global.exception; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + String name(); + + HttpStatus getHttpStatus(); + + String getMessage(); + +} \ No newline at end of file diff --git a/src/main/java/com/cake/pop/global/exception/ErrorResponse.java b/src/main/java/com/cake/pop/global/exception/ErrorResponse.java new file mode 100644 index 0000000..3720e71 --- /dev/null +++ b/src/main/java/com/cake/pop/global/exception/ErrorResponse.java @@ -0,0 +1,8 @@ +package com.cake.pop.global.exception; + +public record ErrorResponse( + + String message, + String code +) { +} diff --git a/src/main/java/com/cake/pop/global/exception/ErrorResponseDto.java b/src/main/java/com/cake/pop/global/exception/ErrorResponseDto.java new file mode 100644 index 0000000..7b9a144 --- /dev/null +++ b/src/main/java/com/cake/pop/global/exception/ErrorResponseDto.java @@ -0,0 +1,40 @@ +package com.cake.pop.global.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.FieldError; + +import java.util.List; + +@Getter +@Builder +@RequiredArgsConstructor +public class ErrorResponseDto { + + private final String code; + private final String message; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List errors; + + /** + * @Valid๋ฅผ ์‚ฌ์šฉํ–ˆ์„ ๋•Œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•œ ๊ฒฝ์šฐ ์–ด๋А ํ•„๋“œ์—์„œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋Š”์ง€ ์‘๋‹ต์„ ์œ„ํ•œ ValidationError๋ฅผ ๋‚ด๋ถ€ ์ •์  ํด๋ž˜์Šค + */ + @Getter + @Builder + @RequiredArgsConstructor + public static class ValidationError { + + private final String field; + private final String message; + + public static ValidationError of(final FieldError fieldError) { + return ValidationError.builder() + .field(fieldError.getField()) + .message(fieldError.getDefaultMessage()) + .build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cake/pop/global/exception/GlobalExceptionHandler.java b/src/main/java/com/cake/pop/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d4f570d --- /dev/null +++ b/src/main/java/com/cake/pop/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,109 @@ +package com.cake.pop.global.exception; + +import static com.cake.pop.global.exception.CommonErrorCode.INVALID_PARAMETER; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.lang.NonNull; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + + +@RequiredArgsConstructor +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + private final ObjectMapper objectMapper; + + @ExceptionHandler(RestApiException.class) + public ResponseEntity handleCustomException(RestApiException e) { + ErrorCode errorCode = e.getErrorCode(); + return handleExceptionInternal(errorCode); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + log.warn("handleIllegalArgument", e); + ErrorCode errorCode = INVALID_PARAMETER; + return handleExceptionInternal(errorCode); + } + + @ExceptionHandler({Exception.class}) + public ResponseEntity handleAllException(Exception ex) { + log.warn("handleAllException", ex); + ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; + return handleExceptionInternal(errorCode); + } + + private ResponseEntity handleExceptionInternal(ErrorCode errorCode) { + return ResponseEntity.status(errorCode.getHttpStatus()).body(makeErrorResponseDto(errorCode)); + } + + private ResponseEntity handleExceptionInternal(ErrorCode errorCode, + String customMessage) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(ErrorResponseDto.builder() + .code(errorCode.name()) + .message(errorCode.getMessage() + customMessage) + .build()); + } + + private ErrorResponseDto makeErrorResponseDto(ErrorCode errorCode) { + return ErrorResponseDto.builder().code(errorCode.name()).message(errorCode.getMessage()) + .build(); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + @NonNull MethodArgumentNotValidException ex, + @NonNull HttpHeaders headers, + @NonNull HttpStatusCode status, + @NonNull WebRequest request) { + log.warn(ex.getMessage(), ex); + + // ํ•„๋“œ ์˜ค๋ฅ˜ ์šฐ์„  ํ™•์ธ + String errMessage; + if (!ex.getBindingResult().getFieldErrors().isEmpty()) { + errMessage = ex.getBindingResult().getFieldErrors().get(0).getDefaultMessage(); + } + // ํด๋ž˜์Šค ๋ ˆ๋ฒจ(Global) ์˜ค๋ฅ˜ ํ™•์ธ + else if (!ex.getBindingResult().getGlobalErrors().isEmpty()) { + errMessage = ex.getBindingResult().getGlobalErrors().get(0).getDefaultMessage(); + } + // ๊ธฐ๋ณธ ๋ฉ”์‹œ์ง€ + else { + errMessage = "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."; + } + + return ResponseEntity.badRequest() + .body(new ErrorResponseDto("404", errMessage, Collections.emptyList())); + } + + @Override + protected ResponseEntity handleHttpMessageNotReadable( + @NonNull HttpMessageNotReadableException e, + @NonNull HttpHeaders headers, + @NonNull HttpStatusCode status, + @NonNull WebRequest request) { + log.warn("handleHttpMessageNotReadable", e); + ErrorCode errorCode = INVALID_PARAMETER; + if (e.getCause() instanceof MismatchedInputException mismatchedInputException) { + String fieldName = mismatchedInputException.getPath().isEmpty() ? "unknown" + : mismatchedInputException.getPath().get(0).getFieldName(); + return handleExceptionInternal(errorCode, " in field: " + fieldName); + } + return handleExceptionInternal(errorCode); + } + +} \ No newline at end of file diff --git a/src/main/java/com/cake/pop/global/exception/RestApiException.java b/src/main/java/com/cake/pop/global/exception/RestApiException.java new file mode 100644 index 0000000..a012012 --- /dev/null +++ b/src/main/java/com/cake/pop/global/exception/RestApiException.java @@ -0,0 +1,14 @@ +package com.cake.pop.global.exception; + +import lombok.Getter; + +@Getter +public class RestApiException extends RuntimeException { + + private final ErrorCode errorCode; + + public RestApiException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/com/cake/pop/global/redis/exception/RedisErrorCode.java b/src/main/java/com/cake/pop/global/redis/exception/RedisErrorCode.java new file mode 100644 index 0000000..965851e --- /dev/null +++ b/src/main/java/com/cake/pop/global/redis/exception/RedisErrorCode.java @@ -0,0 +1,22 @@ +package com.cake.pop.global.redis.exception; + +import org.springframework.http.HttpStatus; + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum RedisErrorCode implements ErrorCode { + + REDIS_SAVE_ERROR(HttpStatus.NOT_FOUND, "์ €์žฅํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."), + REDIS_FIND_ERROR(HttpStatus.NOT_FOUND, "๊ฐ’์„ ์ฐพ๋Š” ๋„์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + REDIS_DELETE_ERROR(HttpStatus.NOT_FOUND, "์‚ญ์ œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."), + REDIS_EXPIRE_ERROR(HttpStatus.NOT_FOUND, "๋งŒ๋ฃŒ์‹œํ‚ค์ง€ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."), + REDIS_EXPIRED_ERROR(HttpStatus.NOT_FOUND, "๋งŒ๋ฃŒ๋œ ํ‚ค ์ž…๋‹ˆ๋‹ค."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cake/pop/global/redis/util/RedisUtil.java b/src/main/java/com/cake/pop/global/redis/util/RedisUtil.java new file mode 100644 index 0000000..5f45f99 --- /dev/null +++ b/src/main/java/com/cake/pop/global/redis/util/RedisUtil.java @@ -0,0 +1,88 @@ +package com.cake.pop.global.redis.util; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Component; + +import com.cake.pop.global.exception.RestApiException; +import com.cake.pop.global.redis.exception.RedisErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisUtil { + + private final RedisTemplate redisTemplate; + + public void setValues(String key, String data) { + try { + ValueOperations values = redisTemplate.opsForValue(); + values.set(key, data); + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_SAVE_ERROR); + } + + } + + public void setValues(String key, String data, Duration duration) { + try { + ValueOperations values = redisTemplate.opsForValue(); + values.set(key, data, duration); + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_SAVE_ERROR); + } + } + + public String getValues(String key) { + try { + ValueOperations values = redisTemplate.opsForValue(); + if (values.get(key) == null) { + return "false"; + } + return (String)values.get(key); + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_FIND_ERROR); + } + } + + public void deleteValues(String key) { + try { + redisTemplate.delete(key); + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_DELETE_ERROR); + } + } + + public void expireValues(String key, int timeout) { + try { + redisTemplate.expire(key, timeout, TimeUnit.MILLISECONDS); + + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_EXPIRE_ERROR); + } + } + + public boolean validateData(String key, String data) { + String findValues = this.getValues(key); + if (Objects.equals(findValues, "false")) { + throw new RestApiException(RedisErrorCode.REDIS_FIND_ERROR); + } + + return Objects.equals(findValues, data); + } + + public void validateExpiredFromKey(String key) { + Long ttl = redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); + if (ttl == null || ttl <= 0) { + throw new RestApiException(RedisErrorCode.REDIS_EXPIRED_ERROR); + } + } + +} diff --git a/src/test/java/com/cake/pop/PopApplicationTests.java b/src/test/java/com/cake/pop/PopApplicationTests.java new file mode 100644 index 0000000..a7d930c --- /dev/null +++ b/src/test/java/com/cake/pop/PopApplicationTests.java @@ -0,0 +1,13 @@ +package com.cake.pop; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class PopApplicationTests { + + @Test + void contextLoads() { + } + +}