diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..48fc58a Binary files /dev/null and b/.DS_Store differ 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/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..045cc4e --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,68 @@ +name: CICD + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 #github actions의 탬플릿(자동으로 pull받음) + - name: Install JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Verify files and directories + run: | + echo "Current directory:" + pwd + echo "Files in current directory:" + ls -al + + - name: Build with Gradle + run: | + mkdir -p ./src/main/resources + echo ${{ secrets.APPLICATION }} | base64 --decode > ./src/main/resources/application.yml + echo ${{ secrets.APPLICATION_PROD }} | base64 --decode > ./src/main/resources/application-prod.yml + echo ${{ secrets.APPLICATION_LOCAL }} | base64 --decode > ./src/main/resources/application-local.yml + chmod 777 ./gradlew + ./gradlew clean build -x test #jar파일 생성 + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build Docker (create Docker image) + run: docker build --platform linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/ceos_vote:latest . + - name: Push Docker + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/ceos_vote:latest + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Docker compose #ec2로 이동해서 ec2에 접속 + uses: appleboy/ssh-action@master + with: + username: ubuntu + host: ${{ secrets.VOTE_SERVER_IP }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/ceos_vote:latest + if [ $(sudo docker ps -q --filter "name=ceos_vote") ]; then + echo "Stopping and removing existing container..." + sudo docker stop ceos_vote + sudo docker rm ceos_vote + fi + sudo docker run -d --name ceos_vote -p 8080:8080 ${{ secrets.DOCKERHUB_USERNAME }}/ceos_vote:latest + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6328b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +/resources +application.yml +application-local.yml +application-prod.yml + + + +vote/HELP.md +.gradle +build/ +!../gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..8c129a5 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..4fcec57 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..2809a13 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..068bc1d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40be9f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# 1. Java 이미지를 기반으로 설정 +FROM openjdk:17-jdk + +ARG JAR_FILE=/build/libs/*.jar + +# 3. JAR 파일 복사 +COPY ${JAR_FILE} app.jar + +# 4. 환경 변수로 Spring Profile 설정 (기본값: local) +ENV SPRING_PROFILES_ACTIVE=prod + +# 5. 애플리케이션 실행 +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "app.jar"] diff --git a/HELP.md b/HELP.md new file mode 100644 index 0000000..1052f41 --- /dev/null +++ b/HELP.md @@ -0,0 +1,25 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.1/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.1/gradle-plugin/packaging-oci-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.1/reference/web/servlet.html) +* [Spring Data JPA](https://docs.spring.io/spring-boot/3.4.1/reference/data/sql.html#data.sql.jpa-and-spring-data) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) +* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) +* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/README.md b/README.md index 76efd88..984967f 100644 --- a/README.md +++ b/README.md @@ -1 +1,43 @@ -# spring_vote_20th \ No newline at end of file +# spring_vote_20th + +### ERD + +image + +### API 명세서 + +image + + +### 테스트 + +<팀 조회> + +image + + +<팀 투표> + +image + + +<본인 팀 투표> + +image + + +<후보자 조회> + +image + + +<중복 투표> + +image + + +<투표 결과 조회> + +image + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..d7578b8 --- /dev/null +++ b/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.1' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'photoground.ceos' +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.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + 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-security' + +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 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..e2847c8 --- /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.11.1-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..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/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 +' "$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, 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..bfce6e0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'vote' diff --git a/src/main/java/photoground/ceos/vote/VoteApplication.java b/src/main/java/photoground/ceos/vote/VoteApplication.java new file mode 100644 index 0000000..2537e7e --- /dev/null +++ b/src/main/java/photoground/ceos/vote/VoteApplication.java @@ -0,0 +1,15 @@ +package photoground.ceos.vote; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class VoteApplication { + + public static void main(String[] args) { + SpringApplication.run(VoteApplication.class, args); + } + +} diff --git a/src/main/java/photoground/ceos/vote/domain/candidate/entity/Candidate.java b/src/main/java/photoground/ceos/vote/domain/candidate/entity/Candidate.java new file mode 100644 index 0000000..de94e2f --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/candidate/entity/Candidate.java @@ -0,0 +1,47 @@ +package photoground.ceos.vote.domain.candidate.entity; + +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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import photoground.ceos.vote.domain.member.entity.Part; +import photoground.ceos.vote.domain.member.entity.Team; +import photoground.ceos.vote.global.entity.BaseTimeEntity; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Builder +@AllArgsConstructor +public class Candidate extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "candidate_id") + private Long id; + + private String name; + + @Builder.Default + private Integer voteNum = 0; + + @Enumerated(EnumType.STRING) + private Part part; + + @Enumerated(EnumType.STRING) + private Team team; + + + public void increaseVoteNum() { + voteNum++; + } + +} diff --git a/src/main/java/photoground/ceos/vote/domain/candidate/repository/CandidateRepository.java b/src/main/java/photoground/ceos/vote/domain/candidate/repository/CandidateRepository.java new file mode 100644 index 0000000..ebd85cd --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/candidate/repository/CandidateRepository.java @@ -0,0 +1,12 @@ +package photoground.ceos.vote.domain.candidate.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import photoground.ceos.vote.domain.candidate.entity.Candidate; +import photoground.ceos.vote.domain.member.entity.Part; + +public interface CandidateRepository extends JpaRepository { + + List findByPart(Part part); + +} diff --git a/src/main/java/photoground/ceos/vote/domain/candidate/service/CandidateService.java b/src/main/java/photoground/ceos/vote/domain/candidate/service/CandidateService.java new file mode 100644 index 0000000..2ae9269 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/candidate/service/CandidateService.java @@ -0,0 +1,34 @@ +package photoground.ceos.vote.domain.candidate.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import photoground.ceos.vote.domain.candidate.entity.Candidate; +import photoground.ceos.vote.domain.candidate.repository.CandidateRepository; +import photoground.ceos.vote.domain.member.entity.Part; +import photoground.ceos.vote.global.exception.CustomException; +import photoground.ceos.vote.global.exception.ErrorCode; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CandidateService { + + private final CandidateRepository candidateRepository; + + + public List findByPart(Part part) { + return candidateRepository.findByPart(part); + } + + public boolean existsById(Long candidateId) { + return candidateRepository.existsById(candidateId); + } + + public Candidate findById(Long candidateId) { + return candidateRepository.findById(candidateId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_CANDIDATE)); + } + +} diff --git a/src/main/java/photoground/ceos/vote/domain/member/controller/AdminController.java b/src/main/java/photoground/ceos/vote/domain/member/controller/AdminController.java new file mode 100644 index 0000000..b780462 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/controller/AdminController.java @@ -0,0 +1,16 @@ +package photoground.ceos.vote.domain.member.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +@ResponseBody +public class AdminController { + + @GetMapping("/admin") + public String adminP() { + + return "admin Controller"; + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/domain/member/controller/MainController.java b/src/main/java/photoground/ceos/vote/domain/member/controller/MainController.java new file mode 100644 index 0000000..b2a104a --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/controller/MainController.java @@ -0,0 +1,19 @@ +package photoground.ceos.vote.domain.member.controller; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +@ResponseBody +public class MainController { + + @GetMapping("/") + public String mainP() { + + String name = SecurityContextHolder.getContext().getAuthentication().getName(); + + return "Main Controller : "+name; + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/domain/member/controller/MemberController.java b/src/main/java/photoground/ceos/vote/domain/member/controller/MemberController.java new file mode 100644 index 0000000..d7e3714 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/controller/MemberController.java @@ -0,0 +1,27 @@ +package photoground.ceos.vote.domain.member.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import photoground.ceos.vote.domain.member.dto.JoinRequestDto; +import photoground.ceos.vote.domain.member.service.MemberService; +import photoground.ceos.vote.global.exception.CustomException; +import photoground.ceos.vote.global.exception.ErrorResponse; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/member") +public class MemberController { + + private final MemberService memberService; + + @PostMapping("/join") + public ResponseEntity joinProcess(@RequestBody JoinRequestDto joinDTO) { + memberService.joinProcess(joinDTO); + return ResponseEntity.ok("회원가입이 완료되었습니다."); + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/member/dto/CustomUserDetails.java b/src/main/java/photoground/ceos/vote/domain/member/dto/CustomUserDetails.java new file mode 100644 index 0000000..b0cd73b --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/dto/CustomUserDetails.java @@ -0,0 +1,76 @@ +package photoground.ceos.vote.domain.member.dto; + +import java.util.ArrayList; +import java.util.Collection; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import photoground.ceos.vote.domain.member.entity.Member; + +public class CustomUserDetails implements UserDetails { + + private final Member member; + + public CustomUserDetails(Member member) { + + this.member = member; + } + + + @Override + public Collection getAuthorities() { + + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + + @Override + public String getAuthority() { + + return member.getRole().getAuthority(); + } + }); + + return collection; + } + + @Override + public String getPassword() { + + return member.getPassword(); + } + + @Override + public String getUsername() { + + return member.getUsername(); + } + + public Long getMemberId() { + + return member.getId(); + } + + @Override + public boolean isAccountNonExpired() { + + return true; + } + + @Override + public boolean isAccountNonLocked() { + + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + + return true; + } + + @Override + public boolean isEnabled() { + + return true; + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/domain/member/dto/JoinRequestDto.java b/src/main/java/photoground/ceos/vote/domain/member/dto/JoinRequestDto.java new file mode 100644 index 0000000..e3a541c --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/dto/JoinRequestDto.java @@ -0,0 +1,47 @@ +package photoground.ceos.vote.domain.member.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.antlr.v4.runtime.misc.NotNull; +import photoground.ceos.vote.domain.member.entity.Member; +import photoground.ceos.vote.domain.member.entity.Part; +import photoground.ceos.vote.domain.member.entity.Team; +import photoground.ceos.vote.domain.member.entity.UserRole; + +@Getter +@NoArgsConstructor +public class JoinRequestDto { + + private String username; + private String password; + private String email; + private String name; + private UserRole role; + private Part part; + private Team team; + + @Builder + public JoinRequestDto(String username, String password, String email, String name, UserRole role, Part part, Team team) { + this.name = name; + this.username = username; + this.password = password; + this.email = email; + this.role = role; + this.part = part; + this.team = team; + } + + public Member toEntity(String encryptedPassword, UserRole role) { + return Member.builder() + .username(this.username) + .password(encryptedPassword) + .email(this.email) + .name(this.name) + .role(role) + .part(this.part) + .team(this.team) + .build(); + } +} + diff --git a/src/main/java/photoground/ceos/vote/domain/member/entity/Member.java b/src/main/java/photoground/ceos/vote/domain/member/entity/Member.java new file mode 100644 index 0000000..9eed842 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/entity/Member.java @@ -0,0 +1,43 @@ +package photoground.ceos.vote.domain.member.entity; + +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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import photoground.ceos.vote.global.entity.BaseTimeEntity; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Builder +@AllArgsConstructor +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + private String username; + private String password; + private String email; + private String name; + + @Enumerated(EnumType.STRING) + private UserRole role; + + @Enumerated(EnumType.STRING) + private Part part; + + @Enumerated(EnumType.STRING) + private Team team; + +} diff --git a/src/main/java/photoground/ceos/vote/domain/member/entity/Part.java b/src/main/java/photoground/ceos/vote/domain/member/entity/Part.java new file mode 100644 index 0000000..5b5a063 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/entity/Part.java @@ -0,0 +1,11 @@ +package photoground.ceos.vote.domain.member.entity; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Part { + FRONTEND("프론트엔드"), + BACKEND("백엔드"); + + private final String name; +} diff --git a/src/main/java/photoground/ceos/vote/domain/member/entity/Team.java b/src/main/java/photoground/ceos/vote/domain/member/entity/Team.java new file mode 100644 index 0000000..a545323 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/entity/Team.java @@ -0,0 +1,28 @@ +package photoground.ceos.vote.domain.member.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import photoground.ceos.vote.global.exception.CustomException; +import photoground.ceos.vote.global.exception.ErrorCode; + +@AllArgsConstructor +@Getter +public enum Team { + PHOTO_GROUND("포토그라운드"), + ANGEL_BRIDGE("엔젤브릿지"), + PEDAL_GENIE("페달지니"), + CAKE_WAY("케이크WAY"), + CUPFEE_DEAL("커피딜"); + + private final String name; + + // 팀명 존재여부 체크 + public static Team validateTeamName(String teamName) { + for (Team team : Team.values()) { + if (team.getName().equals(teamName)) { + return team; + } + } + throw new CustomException(ErrorCode.NOT_FOUND_TEAM_NAME); + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/member/entity/UserRole.java b/src/main/java/photoground/ceos/vote/domain/member/entity/UserRole.java new file mode 100644 index 0000000..f9199a0 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/entity/UserRole.java @@ -0,0 +1,26 @@ +package photoground.ceos.vote.domain.member.entity; + +import lombok.Getter; + +@Getter +public enum UserRole { + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"), + MODERATOR("ROLE_MODERATOR"); + + private final String authority; + + UserRole(String authority) { + this.authority = authority; + } + + public static UserRole fromAuthority(String authority) { + for (UserRole role : UserRole.values()) { + if (role.authority.equalsIgnoreCase(authority)) { + return role; + } + } + throw new IllegalArgumentException("Invalid authority: " + authority); + } + +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/domain/member/repository/MemberRepository.java b/src/main/java/photoground/ceos/vote/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..fc51a3b --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/repository/MemberRepository.java @@ -0,0 +1,13 @@ +package photoground.ceos.vote.domain.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import photoground.ceos.vote.domain.member.entity.Member; + +public interface MemberRepository extends JpaRepository { + + boolean existsByUsername(String username); + + boolean existsByEmail(String email); + + Member findByUsername(String username); +} diff --git a/src/main/java/photoground/ceos/vote/domain/member/service/CustomUserDetailsService.java b/src/main/java/photoground/ceos/vote/domain/member/service/CustomUserDetailsService.java new file mode 100644 index 0000000..0b58096 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/service/CustomUserDetailsService.java @@ -0,0 +1,35 @@ +package photoground.ceos.vote.domain.member.service; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import photoground.ceos.vote.domain.member.dto.CustomUserDetails; +import photoground.ceos.vote.domain.member.entity.Member; +import photoground.ceos.vote.domain.member.repository.MemberRepository; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + public CustomUserDetailsService(MemberRepository memberRepository) { + + this.memberRepository = memberRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + + //DB에서 조회 + Member userData = memberRepository.findByUsername(username); + + if (userData != null) { + + //UserDetails에 담아서 return하면 AutneticationManager가 검증 함 + return new CustomUserDetails(userData); + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/domain/member/service/MemberService.java b/src/main/java/photoground/ceos/vote/domain/member/service/MemberService.java new file mode 100644 index 0000000..1148604 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/member/service/MemberService.java @@ -0,0 +1,57 @@ +package photoground.ceos.vote.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import photoground.ceos.vote.domain.member.dto.JoinRequestDto; +import photoground.ceos.vote.domain.member.entity.Member; +import photoground.ceos.vote.domain.member.entity.UserRole; +import photoground.ceos.vote.domain.member.repository.MemberRepository; +import photoground.ceos.vote.global.exception.CustomException; +import photoground.ceos.vote.global.exception.ErrorCode; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + private final MemberRepository memberRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + @Transactional + public void joinProcess(JoinRequestDto joinDTO) { + + String username = joinDTO.getUsername(); + String password = joinDTO.getPassword(); + String email = joinDTO.getEmail(); + + // Username 중복 확인 + boolean isUsernameExist = memberRepository.existsByUsername(username); + if (isUsernameExist) { + throw new CustomException(ErrorCode.DUPLICATE_USERNAME); + } + + // Email 중복 확인 + boolean isEmailExist = memberRepository.existsByEmail(email); + if (isEmailExist) { + throw new CustomException(ErrorCode.DUPLICATE_EMAIL); + } + + // 비밀번호 null 확인 + if (password == null) { + throw new CustomException(ErrorCode.INVALID_PASSWORD); + } + + // 기본 유저 권한은 USER로 설정 + Member member = joinDTO.toEntity(bCryptPasswordEncoder.encode(password), UserRole.USER); + memberRepository.save(member); + } + + + public Member findById(Long candidateId) { + return memberRepository.findById(candidateId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_MEMBER)); + } + + +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/controller/VoteController.java b/src/main/java/photoground/ceos/vote/domain/vote/controller/VoteController.java new file mode 100644 index 0000000..ae64498 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/controller/VoteController.java @@ -0,0 +1,88 @@ +package photoground.ceos.vote.domain.vote.controller; + +import java.util.HashMap; +import java.util.Map; +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.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; +import photoground.ceos.vote.domain.member.dto.CustomUserDetails; +import photoground.ceos.vote.domain.member.entity.Part; +import photoground.ceos.vote.domain.vote.dto.LeaderListDTO; +import photoground.ceos.vote.domain.vote.dto.LeaderResultListDTO; +import photoground.ceos.vote.domain.vote.dto.TeamListDTO; +import photoground.ceos.vote.domain.vote.dto.TeamResultListDTO; +import photoground.ceos.vote.domain.vote.dto.VoteLeaderDTO; +import photoground.ceos.vote.domain.vote.dto.VoteTeamDTO; +import photoground.ceos.vote.domain.vote.service.VoteService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/votes") +public class VoteController { + + private final VoteService voteService; + + //파트장 후보 조회 + @GetMapping("/leader") + public ResponseEntity showCandidates(@RequestParam Part part) { + + LeaderListDTO candidates = voteService.showLeaders(part); + return ResponseEntity.ok(candidates); + } + + //팀 후보 조회 + @GetMapping("/team") + public ResponseEntity showCandidates() { + + TeamListDTO candidates = voteService.showTeams(); + return ResponseEntity.ok(candidates); + } + + //파트장 투표 + @PostMapping("/leader") + public ResponseEntity> LeaderVote(@RequestBody VoteLeaderDTO voteDTO, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + Long userId = customUserDetails.getMemberId(); + voteService.voteLeader(voteDTO, userId); + + Map response = new HashMap<>(); + response.put("message", "파트장 투표가 완료되었습니다."); + return ResponseEntity.ok(response); + } + + //팀 투표 + @PostMapping("/team") + public ResponseEntity> TeamVote(@RequestBody VoteTeamDTO voteDTO, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + + Long voterId = customUserDetails.getMemberId(); + voteService.voteTeam(voteDTO, voterId); + + Map response = new HashMap<>(); + response.put("message", "데모데이 투표가 완료되었습니다."); + return ResponseEntity.ok(response); + } + + //파트장 투표 결과 조회 + @GetMapping("/leader/result") + public ResponseEntity getLeaderResult(@RequestParam Part part) { + + LeaderResultListDTO result = voteService.getLeaderResult(part); + return ResponseEntity.ok(result); + } + + //팀 투표 결과 조회 + @GetMapping("/team/result") + public ResponseEntity getTeamResult() { + + TeamResultListDTO result = voteService.getTeamResult(); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderDTO.java b/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderDTO.java new file mode 100644 index 0000000..89e3eb4 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderDTO.java @@ -0,0 +1,22 @@ +package photoground.ceos.vote.domain.vote.dto; + +import lombok.Builder; +import lombok.Getter; +import photoground.ceos.vote.domain.candidate.entity.Candidate; + +@Getter +@Builder +public class LeaderDTO { + + private Long leaderId; + private String leaderName; + private String teamName; + + public static LeaderDTO from(Candidate candidate) { + return LeaderDTO.builder() + .leaderId(candidate.getId()) + .leaderName(candidate.getName()) + .teamName(candidate.getTeam().getName()) + .build(); + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderListDTO.java b/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderListDTO.java new file mode 100644 index 0000000..820f23c --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderListDTO.java @@ -0,0 +1,19 @@ +package photoground.ceos.vote.domain.vote.dto; + +import java.util.List; +import lombok.Getter; + +@Getter +public class LeaderListDTO { + + private List leaders; + + + public LeaderListDTO(List leaders) { + this.leaders = leaders; + } + + public static LeaderListDTO from(List leaders) { + return new LeaderListDTO(leaders); + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderResultDTO.java b/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderResultDTO.java new file mode 100644 index 0000000..a14cf9d --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderResultDTO.java @@ -0,0 +1,25 @@ +package photoground.ceos.vote.domain.vote.dto; + +import lombok.Builder; +import lombok.Getter; +import photoground.ceos.vote.domain.candidate.entity.Candidate; + +@Getter +@Builder +public class LeaderResultDTO { + + private String leaderName; + private Long leaderId; + private String teamName; + private int voteNum; + + public static LeaderResultDTO from(Candidate candidate) { + return LeaderResultDTO.builder() + .leaderName(candidate.getName()) + .leaderId(candidate.getId()) + .teamName(candidate.getTeam().getName()) + .voteNum(candidate.getVoteNum()) + .build(); + } + +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderResultListDTO.java b/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderResultListDTO.java new file mode 100644 index 0000000..7780876 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/dto/LeaderResultListDTO.java @@ -0,0 +1,19 @@ +package photoground.ceos.vote.domain.vote.dto; + +import java.util.List; +import lombok.Getter; + +@Getter +public class LeaderResultListDTO { + + private List leaderResults; + + + public LeaderResultListDTO(List leaderResults) { + this.leaderResults = leaderResults; + } + + public static LeaderResultListDTO from(List leaderResults) { + return new LeaderResultListDTO(leaderResults); + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/dto/TeamListDTO.java b/src/main/java/photoground/ceos/vote/domain/vote/dto/TeamListDTO.java new file mode 100644 index 0000000..411254c --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/dto/TeamListDTO.java @@ -0,0 +1,19 @@ +package photoground.ceos.vote.domain.vote.dto; + +import java.util.List; +import lombok.Getter; +import photoground.ceos.vote.domain.member.entity.Team; + +@Getter +public class TeamListDTO { + + private List teams; + + public TeamListDTO(List teams) { + this.teams = teams; + } + + public static TeamListDTO from(List teams) { + return new TeamListDTO(teams); + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/dto/TeamResultDTO.java b/src/main/java/photoground/ceos/vote/domain/vote/dto/TeamResultDTO.java new file mode 100644 index 0000000..96787aa --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/dto/TeamResultDTO.java @@ -0,0 +1,25 @@ +package photoground.ceos.vote.domain.vote.dto; + +import java.util.List; +import java.util.Map; +import lombok.Getter; +import photoground.ceos.vote.domain.member.entity.Team; + +@Getter +public class TeamResultDTO { + + private String teamName; + private int voteNum; + + public TeamResultDTO(String teamName, int voteNum) { + this.teamName = teamName; + this.voteNum = voteNum; + } + + public static List from(Map res) { + return res.entrySet().stream() + .map(entry -> new TeamResultDTO(entry.getKey().getName(), entry.getValue())) + .sorted((dto1, dto2) -> Integer.compare(dto2.getVoteNum(), dto1.getVoteNum())) + .toList(); + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/dto/TeamResultListDTO.java b/src/main/java/photoground/ceos/vote/domain/vote/dto/TeamResultListDTO.java new file mode 100644 index 0000000..254989a --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/dto/TeamResultListDTO.java @@ -0,0 +1,19 @@ +package photoground.ceos.vote.domain.vote.dto; + +import java.util.List; +import lombok.Getter; + +@Getter +public class TeamResultListDTO { + + private List teamResults; + + + public TeamResultListDTO(List teamResults) { + this.teamResults = teamResults; + } + + public static TeamResultListDTO from(List teamResults) { + return new TeamResultListDTO(teamResults); + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/dto/VoteLeaderDTO.java b/src/main/java/photoground/ceos/vote/domain/vote/dto/VoteLeaderDTO.java new file mode 100644 index 0000000..166b35c --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/dto/VoteLeaderDTO.java @@ -0,0 +1,9 @@ +package photoground.ceos.vote.domain.vote.dto; + +import lombok.Getter; + +@Getter +public class VoteLeaderDTO { + + private Long leaderId; +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/dto/VoteTeamDTO.java b/src/main/java/photoground/ceos/vote/domain/vote/dto/VoteTeamDTO.java new file mode 100644 index 0000000..58d7a2e --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/dto/VoteTeamDTO.java @@ -0,0 +1,9 @@ +package photoground.ceos.vote.domain.vote.dto; + +import lombok.Getter; + +@Getter +public class VoteTeamDTO { + + private String teamName; +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/entity/LeaderVote.java b/src/main/java/photoground/ceos/vote/domain/vote/entity/LeaderVote.java new file mode 100644 index 0000000..6ad32fc --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/entity/LeaderVote.java @@ -0,0 +1,28 @@ +package photoground.ceos.vote.domain.vote.entity; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import photoground.ceos.vote.domain.candidate.entity.Candidate; +import photoground.ceos.vote.domain.member.entity.Member; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@DiscriminatorValue("L") +public class LeaderVote extends Vote { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "leader_id") + private Candidate leader; + + public LeaderVote(Candidate leader, Member voter) { + this.leader = leader; + this.voter = voter; + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/entity/TeamVote.java b/src/main/java/photoground/ceos/vote/domain/vote/entity/TeamVote.java new file mode 100644 index 0000000..0e5ef2d --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/entity/TeamVote.java @@ -0,0 +1,23 @@ +package photoground.ceos.vote.domain.vote.entity; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import photoground.ceos.vote.domain.member.entity.Member; +import photoground.ceos.vote.domain.member.entity.Team; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@DiscriminatorValue("T") +public class TeamVote extends Vote { + + private Team team; + + public TeamVote(Team team, Member voter) { + this.team = team; + this.voter = voter; + } +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/entity/Vote.java b/src/main/java/photoground/ceos/vote/domain/vote/entity/Vote.java new file mode 100644 index 0000000..9175c33 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/entity/Vote.java @@ -0,0 +1,35 @@ +package photoground.ceos.vote.domain.vote.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import photoground.ceos.vote.domain.member.entity.Member; +import photoground.ceos.vote.global.entity.BaseTimeEntity; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "vote_type") +public class Vote extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "vote_id") + protected Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voter_id") + protected Member voter; +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/repository/VoteRepository.java b/src/main/java/photoground/ceos/vote/domain/vote/repository/VoteRepository.java new file mode 100644 index 0000000..99e13a4 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/repository/VoteRepository.java @@ -0,0 +1,21 @@ +package photoground.ceos.vote.domain.vote.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import photoground.ceos.vote.domain.vote.entity.LeaderVote; +import photoground.ceos.vote.domain.vote.entity.TeamVote; +import photoground.ceos.vote.domain.vote.entity.Vote; + +public interface VoteRepository extends JpaRepository { + + @Query("SELECT v FROM LeaderVote v WHERE v.voter.id = :voterId") + LeaderVote findLeaderVoteByVoter_Id(Long voterId); + + @Query("SELECT v FROM TeamVote v WHERE v.voter.id= :voterId") + TeamVote findTeamVoteByVoter_Id(Long voterId); + + + @Query("SELECT v FROM TeamVote v") + List findAllTeamVotes(); +} diff --git a/src/main/java/photoground/ceos/vote/domain/vote/service/VoteService.java b/src/main/java/photoground/ceos/vote/domain/vote/service/VoteService.java new file mode 100644 index 0000000..9869490 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/domain/vote/service/VoteService.java @@ -0,0 +1,180 @@ +package photoground.ceos.vote.domain.vote.service; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import photoground.ceos.vote.domain.candidate.entity.Candidate; +import photoground.ceos.vote.domain.candidate.service.CandidateService; +import photoground.ceos.vote.domain.member.entity.Member; +import photoground.ceos.vote.domain.member.entity.Part; +import photoground.ceos.vote.domain.member.entity.Team; +import photoground.ceos.vote.domain.member.service.MemberService; +import photoground.ceos.vote.domain.vote.dto.LeaderDTO; +import photoground.ceos.vote.domain.vote.dto.LeaderListDTO; +import photoground.ceos.vote.domain.vote.dto.LeaderResultDTO; +import photoground.ceos.vote.domain.vote.dto.LeaderResultListDTO; +import photoground.ceos.vote.domain.vote.dto.TeamListDTO; +import photoground.ceos.vote.domain.vote.dto.TeamResultDTO; +import photoground.ceos.vote.domain.vote.dto.TeamResultListDTO; +import photoground.ceos.vote.domain.vote.dto.VoteLeaderDTO; +import photoground.ceos.vote.domain.vote.dto.VoteTeamDTO; +import photoground.ceos.vote.domain.vote.entity.LeaderVote; +import photoground.ceos.vote.domain.vote.entity.TeamVote; +import photoground.ceos.vote.domain.vote.repository.VoteRepository; +import photoground.ceos.vote.global.exception.CustomException; +import photoground.ceos.vote.global.exception.ErrorCode; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VoteService { + + private final VoteRepository voteRepository; + private final MemberService memberService; + private final CandidateService candidateService; + + // 파트장 후보 조회 + public LeaderListDTO showLeaders(Part part) { + + List candidates = candidateService.findByPart(part); + List dtos = candidates.stream().map(LeaderDTO::from + ).toList(); + return LeaderListDTO.from(dtos); + } + + + // 팀 후보 조회 + public TeamListDTO showTeams() { + + List teamName = Arrays.stream(Team.values()).map(Team::getName).toList(); + return TeamListDTO.from(teamName); + + } + + + // 파트장 투표 + @Transactional + public void voteLeader(VoteLeaderDTO voteDTO, Long voterId) { + + //중복 투표 체크 + if (duplicateLeaderVote(voterId)) { + throw new CustomException(ErrorCode.LEADER_VOTE_ALREADY_EXIST); + } + + Long leaderId = voteDTO.getLeaderId(); + + //후보자 id 유효성 체크 + if (!candidateExist(leaderId)) { + throw new CustomException(ErrorCode.LEADER_NOT_EXIST); + } + + Candidate leader = candidateService.findById(leaderId); + Member voter = memberService.findById(voterId); + + //파트 체크 + if (!validPart(leader, voter)) { + throw new CustomException(ErrorCode.INVALID_PART_VOTE); + } + + LeaderVote vote = new LeaderVote(leader, voter); + voteRepository.save(vote); + leader.increaseVoteNum(); + } + + + // 팀 투표 + @Transactional + public void voteTeam(VoteTeamDTO voteDTO, Long voterId) { + + //중복 투표 체크 + if (duplicateTeamVote(voterId)) { + throw new CustomException(ErrorCode.TEAM_VOTE_ALREADY_EXIST); + } + + String teamName = voteDTO.getTeamName(); + //팀명 존재여부 체크 + Team team = Team.validateTeamName(teamName); + + Member voter = memberService.findById(voterId); + + //내 팀 아닌지 체크 + if (!validTeam(team, voter)) { + throw new CustomException(ErrorCode.INVALID_TEAM_VOTE); + } + + TeamVote teamVote = new TeamVote(team, voter); + voteRepository.save(teamVote); + } + + + //파트장 투표 결과 조회 + public LeaderResultListDTO getLeaderResult(Part part) { + + //파트가 part인 멤버들 찾아서 리스트에 담기 + List candidates = candidateService.findByPart(part); + + //voteNum 기준 내림차순 정렬 + List resultList = candidates.stream() + .sorted((m1, m2) -> Long.compare(m2.getVoteNum(), m1.getVoteNum())) + .map(LeaderResultDTO::from) + .toList(); + + return LeaderResultListDTO.from(resultList); + } + + + //팀 투표 결과 조회 + public TeamResultListDTO getTeamResult() { + + Map res = new HashMap<>(); + for (Team team : Team.values()) { + res.put(team, 0); + } + + List teamVotes = voteRepository.findAllTeamVotes(); + teamVotes.forEach(vote + -> res.put(vote.getTeam(), res.get(vote.getTeam()) + 1)); + + List dto = TeamResultDTO.from(res); + return TeamResultListDTO.from(dto); + + + } + + + //중복 투표 체크 (파트장) + private boolean duplicateLeaderVote(Long voterId) { + LeaderVote vote = voteRepository.findLeaderVoteByVoter_Id(voterId); + return vote != null; + } + + //후보자 id 유효성 체크 + private boolean candidateExist(Long candidateId) { + return candidateService.existsById(candidateId); + } + + //파트 체크 + private boolean validPart(Candidate candidate, Member voter) { + Part candidatePart = candidate.getPart(); + Part myPart = voter.getPart(); + + return candidatePart == myPart; + } + + //중복 투표 체크 (데모데이) + private boolean duplicateTeamVote(Long voterId) { + TeamVote vote = voteRepository.findTeamVoteByVoter_Id(voterId); + return vote != null; + } + + //내 팀 아닌지 체크 + private boolean validTeam(Team team, Member voter) { + return team != voter.getTeam(); + } + +} + diff --git a/src/main/java/photoground/ceos/vote/global/config/CorsMvcConfig.java b/src/main/java/photoground/ceos/vote/global/config/CorsMvcConfig.java new file mode 100644 index 0000000..c9f773c --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/config/CorsMvcConfig.java @@ -0,0 +1,16 @@ +package photoground.ceos.vote.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsMvcConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry corsRegistry) { + // 컨트롤러 경로에 대해 요청을 허용 + corsRegistry.addMapping("/**") + .allowedOrigins("http://localhost:3000", "https://react-vote-20th-l62s.vercel.app/"); + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/global/config/SecurityConfig.java b/src/main/java/photoground/ceos/vote/global/config/SecurityConfig.java new file mode 100644 index 0000000..9f4b558 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/config/SecurityConfig.java @@ -0,0 +1,110 @@ +package photoground.ceos.vote.global.config; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import photoground.ceos.vote.domain.member.repository.MemberRepository; +import photoground.ceos.vote.global.entity.RefreshRepository; +import photoground.ceos.vote.global.jwt.CustomLogoutFilter; +import photoground.ceos.vote.global.jwt.JWTFilter; +import photoground.ceos.vote.global.jwt.JWTUtil; +import photoground.ceos.vote.global.jwt.LoginFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final AuthenticationConfiguration authenticationConfiguration; + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + private final MemberRepository memberRepository; + + //AuthenticationManager Bean 등록 + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + + return configuration.getAuthenticationManager(); + } + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + + return new BCryptPasswordEncoder(); + } + + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + //cors 설정 + http.cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins( + Arrays.asList("http://localhost:3000", "https://react-vote-20th-l62s.vercel.app/")); + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + + configuration.setExposedHeaders(Arrays.asList("access", "Set-Cookie")); + + return configuration; + } + }))); + //csrf disable + http.csrf((auth) -> auth.disable()); + + //From 로그인 방식 disable + http.formLogin((auth) -> auth.disable()); + + //http basic 인증 방식 disable + http.httpBasic((auth) -> auth.disable()); + + //경로별 인가 작업 + http.authorizeHttpRequests((auth) -> auth + .requestMatchers("/login", "/", "/api/member/join").permitAll() // 해당 경로는 모든 사용자가 접근 가능 + .requestMatchers(HttpMethod.GET, "/api/votes/team").permitAll() + .requestMatchers(HttpMethod.GET, "/api/votes/leader").permitAll() + .requestMatchers("/admin").hasRole("ADMIN") // admin 경로는 해당 권한을 가진 사용자만 접근 가능. + .requestMatchers("/api/reissue").permitAll() // 리프레시 토큰은 모든 사용자가 접근 가능 + .anyRequest().authenticated()); // 이외의 남은 경로는 로그인한 사용자만 접근 가능 + + // JWT 권한 검증 + http.addFilterAfter(new JWTFilter(jwtUtil, memberRepository), LoginFilter.class); + + // 로그인 필터 설정 (/login) + http.addFilterAt( + new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository), + UsernamePasswordAuthenticationFilter.class); + + // 로그아웃 필터 설정 (/logout) + http + .addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class); + + //세션 설정 (JWT는 세션이 stateless 상태로 설정되어야 함) + http.sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/global/entity/BaseTimeEntity.java b/src/main/java/photoground/ceos/vote/global/entity/BaseTimeEntity.java new file mode 100644 index 0000000..c07e02d --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/entity/BaseTimeEntity.java @@ -0,0 +1,25 @@ +package photoground.ceos.vote.global.entity; + +import jakarta.persistence.Column; +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 BaseTimeEntity { + + @CreatedDate + @Column(name = "created_at", updatable = false, columnDefinition = "timestamp") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", columnDefinition = "timestamp") + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/photoground/ceos/vote/global/entity/RefreshEntity.java b/src/main/java/photoground/ceos/vote/global/entity/RefreshEntity.java new file mode 100644 index 0000000..29f4493 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/entity/RefreshEntity.java @@ -0,0 +1,22 @@ +package photoground.ceos.vote.global.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +public class RefreshEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String username; + private String refresh; + private String expiration; +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/global/entity/RefreshRepository.java b/src/main/java/photoground/ceos/vote/global/entity/RefreshRepository.java new file mode 100644 index 0000000..00c5f40 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/entity/RefreshRepository.java @@ -0,0 +1,12 @@ +package photoground.ceos.vote.global.entity; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +public interface RefreshRepository extends JpaRepository { + + Boolean existsByRefresh(String refresh); + + @Transactional + void deleteByRefresh(String refresh); +} diff --git a/src/main/java/photoground/ceos/vote/global/exception/ApiExceptionHandler.java b/src/main/java/photoground/ceos/vote/global/exception/ApiExceptionHandler.java new file mode 100644 index 0000000..c389e42 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/exception/ApiExceptionHandler.java @@ -0,0 +1,37 @@ +package photoground.ceos.vote.global.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(value = CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + log.error("[handleCustomException] {} : {}", e.getErrorCode().name(), e.getErrorCode().getMessage()); + return ErrorResponse.fromException(e); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + String errorMessage = ex.getBindingResult() + .getAllErrors() + .get(0) + .getDefaultMessage(); // 첫 번째 에러 메시지 가져오기 + + log.error("[Validation Error] {}", errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.builder() + .status(HttpStatus.BAD_REQUEST) + .code("VALIDATION_ERROR") + .message(errorMessage) + .build()); + } + +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/global/exception/CustomException.java b/src/main/java/photoground/ceos/vote/global/exception/CustomException.java new file mode 100644 index 0000000..499b934 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/exception/CustomException.java @@ -0,0 +1,19 @@ +package photoground.ceos.vote.global.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + private ErrorCode errorCode; + + private String info; + + public CustomException(ErrorCode errorCode){ + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String info){ + this.errorCode = errorCode; + this.info = info; + } +} diff --git a/src/main/java/photoground/ceos/vote/global/exception/ErrorCode.java b/src/main/java/photoground/ceos/vote/global/exception/ErrorCode.java new file mode 100644 index 0000000..4860c2b --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/exception/ErrorCode.java @@ -0,0 +1,38 @@ +package photoground.ceos.vote.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + // Auth + BAD_CREDENTIALS(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 잘못되었습니다."), + FAIL_AUTHENTICATION(HttpStatus.UNAUTHORIZED, "로그인에 실패했습니다. 다시 시도해주세요."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다."), + FAIL_AUTHORIZATION(HttpStatus.FORBIDDEN, "권한이 없는 요청입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR"), + DUPLICATE_USERNAME(HttpStatus.BAD_REQUEST, "이미 존재하는 아이디입니다."), + DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "이미 존재하는 이메일입니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호는 반드시 필요합니다."), + + // Vote + LEADER_VOTE_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다."), + LEADER_NOT_EXIST(HttpStatus.NOT_FOUND, "해당 후보자는 존재하지 않습니다."), + INVALID_PART_VOTE(HttpStatus.BAD_REQUEST, "본인의 파트에 해당하는 파트장 투표만 할 수 있습니다."), + NOT_FOUND_TEAM_NAME(HttpStatus.NOT_FOUND, "해당 팀명은 존재하지 않습니다."), + TEAM_VOTE_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다."), + INVALID_TEAM_VOTE(HttpStatus.BAD_REQUEST, "본인 팀에는 투표할 수 없습니다."), + + // CANDIDATE + NOT_FOUND_CANDIDATE(HttpStatus.NOT_FOUND, "해당 id의 후보자는 존재하지 않습니다."), + + // MEMBER + NOT_FOUND_MEMBER(HttpStatus.NOT_FOUND, "해당 id의 사용자는 존재하지 않습니다."); + + private final HttpStatus status; + private final String message; + +} diff --git a/src/main/java/photoground/ceos/vote/global/exception/ErrorResponse.java b/src/main/java/photoground/ceos/vote/global/exception/ErrorResponse.java new file mode 100644 index 0000000..c14aba2 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/exception/ErrorResponse.java @@ -0,0 +1,39 @@ +package photoground.ceos.vote.global.exception; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +@Getter +@Builder +public class ErrorResponse { + + private final HttpStatus status; // HTTP 상태 코드 + private final String code; // 에러 코드 + private final String message; // 에러 메시지 + + // ErrorResponse 생성 메서드 + public static ErrorResponse of(HttpStatus status, String code, String message) { + return ErrorResponse.builder() + .status(status) + .message(message) + .code(code) + .build(); + } + + public static ResponseEntity fromException(CustomException e) { + String message = e.getErrorCode().getMessage(); + if (e.getInfo() != null) { + message += " " + e.getInfo(); // 추가 정보가 있는 경우 결합 + } + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ErrorResponse.builder() + .status(e.getErrorCode().getStatus()) + .code(e.getErrorCode().name()) + .message(message) + .build()); + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/global/jwt/CustomLogoutFilter.java b/src/main/java/photoground/ceos/vote/global/jwt/CustomLogoutFilter.java new file mode 100644 index 0000000..e4bad8e --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/jwt/CustomLogoutFilter.java @@ -0,0 +1,106 @@ +package photoground.ceos.vote.global.jwt; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.web.filter.GenericFilterBean; +import photoground.ceos.vote.global.entity.RefreshRepository; + +public class CustomLogoutFilter extends GenericFilterBean { + + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + + public CustomLogoutFilter(JWTUtil jwtUtil, RefreshRepository refreshRepository) { + + this.jwtUtil = jwtUtil; + this.refreshRepository = refreshRepository; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + + doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain); + } + + private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { + + //path and method verify + String requestUri = request.getRequestURI(); + if (!requestUri.matches("^\\/logout$")) { + + filterChain.doFilter(request, response); + return; + } + String requestMethod = request.getMethod(); + if (!requestMethod.equals("POST")) { + + filterChain.doFilter(request, response); + return; + } + + //get refresh token + String refresh = null; + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + + if (cookie.getName().equals("refresh")) { + + refresh = cookie.getValue(); + } + } + + //refresh null check + if (refresh == null) { + + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + //expired check + try { + jwtUtil.isExpired(refresh); + } catch (ExpiredJwtException e) { + + //response status code + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + // 토큰이 refresh인지 확인 (발급시 페이로드에 명시) + String category = jwtUtil.getCategory(refresh); + if (!category.equals("refresh")) { + + //response status code + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + //DB에 저장되어 있는지 확인 + Boolean isExist = refreshRepository.existsByRefresh(refresh); + if (!isExist) { + + //response status code + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + //로그아웃 진행 + //Refresh 토큰 DB에서 제거 + refreshRepository.deleteByRefresh(refresh); + + //Refresh 토큰 Cookie 값 0 + Cookie cookie = new Cookie("refresh", null); + cookie.setMaxAge(0); + cookie.setPath("/"); + + response.addCookie(cookie); + response.setStatus(HttpServletResponse.SC_OK); + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/global/jwt/JWTFilter.java b/src/main/java/photoground/ceos/vote/global/jwt/JWTFilter.java new file mode 100644 index 0000000..5d1f672 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/jwt/JWTFilter.java @@ -0,0 +1,95 @@ +package photoground.ceos.vote.global.jwt; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; +import photoground.ceos.vote.domain.member.dto.CustomUserDetails; +import photoground.ceos.vote.domain.member.entity.Member; +import photoground.ceos.vote.domain.member.repository.MemberRepository; + +@RequiredArgsConstructor +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + private final MemberRepository memberRepository; + + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + // 헤더에서 access키에 담긴 토큰을 꺼냄 + String accessToken = request.getHeader("access"); + + // 토큰이 없다면 다음 필터로 넘김 + if (accessToken == null) { + + filterChain.doFilter(request, response); + + return; + } + + // 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음 + try { + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e) { + + //response body + PrintWriter writer = response.getWriter(); + writer.print("access token expired"); + + //response status code + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // 토큰이 access인지 확인 (발급시 페이로드에 명시) + String category = jwtUtil.getCategory(accessToken); + + if (!category.equals("access")) { + + //response body + PrintWriter writer = response.getWriter(); + writer.print("invalid access token"); + + //response status code + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + //토큰에서 username과 role 획득 + String username = jwtUtil.getUsername(accessToken); + /* + String role = jwtUtil.getRole(accessToken); + + UserRole userRole = UserRole.fromAuthority(role); + + + Member member = Member.builder() + .username(username) + .password("tempPassword") // 비밀번호는 실제 사용되지 않으므로 임의 값 + .role(userRole) + .build(); + */ + Member member = memberRepository.findByUsername(username); + + //UserDetails에 회원 정보 객체 담기 + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + //스프링 시큐리티 인증 토큰 생성 + Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, + customUserDetails.getAuthorities()); + //세션에 사용자 등록 + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/global/jwt/JWTUtil.java b/src/main/java/photoground/ceos/vote/global/jwt/JWTUtil.java new file mode 100644 index 0000000..610cf18 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/jwt/JWTUtil.java @@ -0,0 +1,54 @@ +package photoground.ceos.vote.global.jwt; + +import io.jsonwebtoken.Jwts; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JWTUtil { // JWTUtil : 0.12.3 ver + + private SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}") String secret) { + // application.yml의 jwt를 가져와 객체 변수로 암호화 (HS256) + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUsername(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload() + .get("username", String.class); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload() + .get("role", String.class); + } + + public String getCategory(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload() + .get("category", String.class); + } + + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration() + .before(new Date()); + } + + + public String createJwt(String category, String username, String role, Long expiredMs) { + + return Jwts.builder() + .claim("category", category) + .claim("username", username) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/photoground/ceos/vote/global/jwt/LoginFilter.java b/src/main/java/photoground/ceos/vote/global/jwt/LoginFilter.java new file mode 100644 index 0000000..d543776 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/jwt/LoginFilter.java @@ -0,0 +1,101 @@ +package photoground.ceos.vote.global.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Collection; +import java.util.Date; +import java.util.Iterator; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import photoground.ceos.vote.global.entity.RefreshEntity; +import photoground.ceos.vote.global.entity.RefreshRepository; + +@RequiredArgsConstructor +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + + //클라이언트 요청에서 username, password 추출 + String username = obtainUsername(request); + String password = obtainPassword(request); + + //스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함 + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, + null); + + //token에 담은 검증을 위한 AuthenticationManager로 전달 + return authenticationManager.authenticate(authToken); + } + + + //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨) + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authentication) { + + //유저 정보 + String username = authentication.getName(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + //토큰 생성 + String access = jwtUtil.createJwt("access", username, role, 3600000L); + String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L); + + //Refresh 토큰 저장 + addRefreshEntity(username, refresh, 86400000L); + + //응답 설정 + response.setHeader("access", access); + response.addCookie(createCookie("refresh", refresh)); + response.setStatus(HttpStatus.OK.value()); + } + + //로그인 실패시 실행하는 메소드 + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) { + response.setStatus(401); + } + + private Cookie createCookie(String key, String value) { + + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(24 * 60 * 60); + //cookie.setSecure(true); + //cookie.setPath("/"); + cookie.setHttpOnly(true); + + return cookie; + } + + private void addRefreshEntity(String username, String refresh, Long expiredMs) { + + Date date = new Date(System.currentTimeMillis() + expiredMs); + + RefreshEntity refreshEntity = new RefreshEntity(); + refreshEntity.setUsername(username); + refreshEntity.setRefresh(refresh); + refreshEntity.setExpiration(date.toString()); + + refreshRepository.save(refreshEntity); + } +} + diff --git a/src/main/java/photoground/ceos/vote/global/jwt/ReissueController.java b/src/main/java/photoground/ceos/vote/global/jwt/ReissueController.java new file mode 100644 index 0000000..611d566 --- /dev/null +++ b/src/main/java/photoground/ceos/vote/global/jwt/ReissueController.java @@ -0,0 +1,110 @@ +package photoground.ceos.vote.global.jwt; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Date; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import photoground.ceos.vote.global.entity.RefreshEntity; +import photoground.ceos.vote.global.entity.RefreshRepository; + +@RestController +@AllArgsConstructor +public class ReissueController { + + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + + // 쿠키를 통해 리프레시 토큰을 받아 AccessToken을 재발급하는 코드 + @PostMapping("/api/reissue") + public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response) { + + //get refresh token + String refresh = null; + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + + if (cookie.getName().equals("refresh")) { + + refresh = cookie.getValue(); + } + } + + if (refresh == null) { + + //response status code + return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST); + } + + //expired check + try { + jwtUtil.isExpired(refresh); + } catch (ExpiredJwtException e) { + + //response status code + return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST); + } + + // 토큰이 refresh인지 확인 (발급시 페이로드에 명시) + String category = jwtUtil.getCategory(refresh); + + if (!category.equals("refresh")) { + + //response status code + return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST); + } + + //DB에 저장되어 있는지 확인 + Boolean isExist = refreshRepository.existsByRefresh(refresh); + if (!isExist) { + + //response body + return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST); + } + + String username = jwtUtil.getUsername(refresh); + String role = jwtUtil.getRole(refresh); + + //make new JWT + String newAccess = jwtUtil.createJwt("access", username, role, 600000L); + String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L); + + //Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후 새 Refresh 토큰 저장 + refreshRepository.deleteByRefresh(refresh); + addRefreshEntity(username, newRefresh, 86400000L); + + //response + response.setHeader("access", newAccess); + response.addCookie(createCookie("refresh", newRefresh)); + + return new ResponseEntity<>(HttpStatus.OK); + } + + private Cookie createCookie(String key, String value) { + + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(24*60*60); + //cookie.setSecure(true); + //cookie.setPath("/"); + cookie.setHttpOnly(true); + + return cookie; + } + + private void addRefreshEntity(String username, String refresh, Long expiredMs) { + + Date date = new Date(System.currentTimeMillis() + expiredMs); + + RefreshEntity refreshEntity = new RefreshEntity(); + refreshEntity.setUsername(username); + refreshEntity.setRefresh(refresh); + refreshEntity.setExpiration(date.toString()); + + refreshRepository.save(refreshEntity); + } +} \ No newline at end of file diff --git a/src/main/java/photoground/ceos/vote/test/TestController.java b/src/main/java/photoground/ceos/vote/test/TestController.java new file mode 100644 index 0000000..e8fb7db --- /dev/null +++ b/src/main/java/photoground/ceos/vote/test/TestController.java @@ -0,0 +1,14 @@ +package photoground.ceos.vote.test; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TestController { + + @GetMapping("/test") + public ResponseEntity test(){ + return ResponseEntity.ok("Hello World"); + } +} diff --git a/src/test/java/photoground/ceos/vote/VoteApplicationTests.java b/src/test/java/photoground/ceos/vote/VoteApplicationTests.java new file mode 100644 index 0000000..c299f77 --- /dev/null +++ b/src/test/java/photoground/ceos/vote/VoteApplicationTests.java @@ -0,0 +1,13 @@ +package photoground.ceos.vote; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class VoteApplicationTests { + + @Test + void contextLoads() { + } + +}