diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..2117700 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,17 @@ +# .coderabbit.yaml +language: "ko-KR" # 한국어 +early_access: false +reviews: + profile: "chill" + request_changes_workflow: true + high_level_summary: true + poem: true + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false + branches: + - "*" +chat: + auto_reply: true \ No newline at end of file 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-form.yml b/.github/ISSUE_TEMPLATE/issue-form.yml new file mode 100644 index 0000000..87b5448 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue-form.yml @@ -0,0 +1,34 @@ +name: 'BE 이슈 생성' +description: 'BE Repo에 이슈를 생성합니다.' +labels: [order] +title: '이슈 이름을 작성해주세요' +body: + + - type: textarea + id: description + attributes: + label: '📋이슈 내용(Description)' + description: '이슈에 대해서 자세히 설명해주세요' + validations: + required: true + + - type: textarea + id: tasks + attributes: + label: '☑️체크리스트(Tasks)' + description: '해당 이슈에 대해 필요한 작업목록을 작성해주세요' + value: | + - [ ] Task1 + - [ ] Task2 + validations: + required: true + + - type: textarea + id: references + attributes: + label: '📁참조(References)' + description: '해당 이슈과 관련된 레퍼런스를 참조해주세요' + value: | + - Reference1 + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..387fd15 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## #️⃣연관된 이슈 + +> ex) #이슈번호, #이슈번호 + +## 📝작업 내용 + +> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능) + +### 스크린샷 (선택) + +## 💬리뷰 요구사항(선택) + +> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요 +> +> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요? \ No newline at end of file diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..7b5e392 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,63 @@ +name: spring-vote CI/CD + +on: + push: + branches: [ main, develop ] + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build jar + run: ./gradlew clean build -x test + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: danaggero/ceos-vote-spring:latest + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - name: Deploy to EC2 via SSH + uses: appleboy/ssh-action@v1.1.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_PRIVATE_KEY }} + script: | + set -e + cd /home/ubuntu/spring-vote-22nd + + git fetch origin + git reset --hard origin/${{ github.ref_name }} + git clean -fd + + # 최신 이미지 받아서 컨테이너 재시작 + sudo docker compose pull + sudo docker compose up -d --remove-orphans + + sudo docker ps \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdcb3b9 Binary files /dev/null and b/.gitignore differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2f7f60 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# --- Build Stage --- +FROM gradle:8.7-jdk21 AS builder +WORKDIR /app + +COPY . . +RUN gradle clean build -x test + + +# --- Run Stage --- +FROM eclipse-temurin:21-jdk +WORKDIR /app + +COPY --from=builder /app/build/libs/*.jar 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..4baca0b --- /dev/null +++ b/build.gradle @@ -0,0 +1,69 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.7' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.ceos' +version = '0.0.1-SNAPSHOT' +description = 'spring-vote-22nd' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + //web + implementation 'org.springframework.boot:spring-boot-starter-web' + + //DB + runtimeOnly 'org.postgresql:postgresql' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + //security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + //validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + //lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + //test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //env + implementation 'me.paulschwarz:spring-dotenv:4.0.0' + +// developmentOnly 'org.springframework.boot:spring-boot-docker-compose' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1138034 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3.8" + +services: + spring: + image: danaggero/ceos-vote-spring:latest + container_name: ceos-vote-spring + env_file: + - .env + expose: + - "8080" + restart: always + + nginx: + image: nginx:alpine + container_name: ceos-vote-nginx + depends_on: + - spring + ports: + - "80:80" + volumes: + - ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + restart: always \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/infra/nginx/default.conf b/infra/nginx/default.conf new file mode 100644 index 0000000..00fc695 --- /dev/null +++ b/infra/nginx/default.conf @@ -0,0 +1,17 @@ +upstream spring_app { + server spring:8080; +} + +server { + listen 80; + listen [::]:80; + server_name _; + + location / { + proxy_pass http://spring_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/menual_ceos-vote.pdf b/menual_ceos-vote.pdf new file mode 100644 index 0000000..7bfc6ac Binary files /dev/null and b/menual_ceos-vote.pdf differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..d4a6f3c --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'spring-vote-22nd' diff --git a/src/main/java/com/ceos/springvote22nd/SpringVote22ndApplication.java b/src/main/java/com/ceos/springvote22nd/SpringVote22ndApplication.java new file mode 100644 index 0000000..84bcf75 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/SpringVote22ndApplication.java @@ -0,0 +1,13 @@ +package com.ceos.springvote22nd; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringVote22ndApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringVote22ndApplication.class, args); + } + +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/auth/controller/AuthController.java b/src/main/java/com/ceos/springvote22nd/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..4ebe7a7 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/auth/controller/AuthController.java @@ -0,0 +1,41 @@ +package com.ceos.springvote22nd.domain.auth.controller; + +import com.ceos.springvote22nd.domain.auth.dto.request.LoginRequestDTO; +import com.ceos.springvote22nd.domain.auth.dto.response.LoginResponseDTO; +import com.ceos.springvote22nd.domain.auth.service.AuthService; +import com.ceos.springvote22nd.domain.common.dto.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "인증 API", description = "로그인/로그아웃") +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "로그인", description = "이메일/비밀번호로 로그인하고 access 토큰을 쿠키로 발급한다.") + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody LoginRequestDTO request, + HttpServletResponse response + ) { + LoginResponseDTO result = authService.login(request, response); + return ResponseEntity.ok(CommonResponse.success(result)); + } + + + @Operation(summary = "로그아웃", description = "access 토큰 쿠키를 삭제한다.") + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletResponse response) { + authService.logout(response); + return ResponseEntity.ok(CommonResponse.success(null)); + } +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/auth/dto/request/LoginRequestDTO.java b/src/main/java/com/ceos/springvote22nd/domain/auth/dto/request/LoginRequestDTO.java new file mode 100644 index 0000000..4102ddf --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/auth/dto/request/LoginRequestDTO.java @@ -0,0 +1,16 @@ +package com.ceos.springvote22nd.domain.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class LoginRequestDTO { + + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; + + @NotBlank(message = "비밀번호를 입력해주세요.") + private String password; +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/auth/dto/response/LoginResponseDTO.java b/src/main/java/com/ceos/springvote22nd/domain/auth/dto/response/LoginResponseDTO.java new file mode 100644 index 0000000..01938a9 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/auth/dto/response/LoginResponseDTO.java @@ -0,0 +1,16 @@ +package com.ceos.springvote22nd.domain.auth.dto.response; + +import com.ceos.springvote22nd.entity.enums.Part; +import com.ceos.springvote22nd.entity.enums.Team; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class LoginResponseDTO { + private Long userId; + private String username; + private String email; + private Team team; + private Part part; +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/ceos/springvote22nd/domain/auth/exception/AuthErrorCode.java new file mode 100644 index 0000000..7b7a967 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/auth/exception/AuthErrorCode.java @@ -0,0 +1,27 @@ +package com.ceos.springvote22nd.domain.auth.exception; + +import com.ceos.springvote22nd.global.config.exception.ResultCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ResultCode { + + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, 1001, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, 1002, "만료된 토큰입니다."), + UNSUPPORTED_TOKEN(HttpStatus.UNAUTHORIZED, 1003, "지원되지 않는 토큰입니다."), + MALFORMED_TOKEN(HttpStatus.UNAUTHORIZED, 1004, "잘못된 형식의 토큰입니다."), + INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, 1005, "잘못된 JWT 서명입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, 1006, "Refresh Token을 찾을 수 없습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, 1007, "유효하지 않은 Refresh Token입니다."), + TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, 1008, "토큰이 존재하지 않습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, 1009, "존재하지 않는 사용자입니다."), + INVALID_CREDENTIALS(HttpStatus.CONFLICT, 1010, "이메일 또는 비밀번호가 일치하지 않습니다."), + INVALID_TOKEN_TYPE(HttpStatus.UNAUTHORIZED, 1011, "토큰 타입이 올바르지 않습니다."); + + private final HttpStatus status; + private final int code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/auth/service/AuthService.java b/src/main/java/com/ceos/springvote22nd/domain/auth/service/AuthService.java new file mode 100644 index 0000000..9cc8b27 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/auth/service/AuthService.java @@ -0,0 +1,57 @@ +package com.ceos.springvote22nd.domain.auth.service; + +import com.ceos.springvote22nd.domain.auth.dto.request.LoginRequestDTO; +import com.ceos.springvote22nd.domain.auth.dto.response.LoginResponseDTO; +import com.ceos.springvote22nd.domain.auth.exception.AuthErrorCode; +import com.ceos.springvote22nd.domain.user.repository.UserRepository; +import com.ceos.springvote22nd.entity.User; +import com.ceos.springvote22nd.global.config.exception.GlobalException; +import com.ceos.springvote22nd.global.config.jwt.CookieUtil; +import com.ceos.springvote22nd.global.config.jwt.JwtProvider; +import com.ceos.springvote22nd.global.config.jwt.JwtValidator; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + private final JwtProvider jwtProvider; + private final JwtValidator jwtValidator; + private final CookieUtil cookieUtil; + + @Transactional + public LoginResponseDTO login(LoginRequestDTO request, HttpServletResponse response) { + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new GlobalException(AuthErrorCode.USER_NOT_FOUND)); + + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new GlobalException(AuthErrorCode.INVALID_CREDENTIALS); + } + + String accessToken = jwtProvider.createAccessToken(user.getId(), user.getEmail()); + + cookieUtil.addAccessTokenCookie(response, accessToken); + + return LoginResponseDTO.builder() + .userId(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .team(user.getTeam()) + .part(user.getPart()) + .build(); + } + + @Transactional + public void logout(HttpServletResponse response) { + cookieUtil.deleteAccessTokenCookie(response); + } +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/common/dto/response/CommonResponse.java b/src/main/java/com/ceos/springvote22nd/domain/common/dto/response/CommonResponse.java new file mode 100644 index 0000000..4301edf --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/common/dto/response/CommonResponse.java @@ -0,0 +1,39 @@ +package com.ceos.springvote22nd.domain.common.dto.response; + +import com.ceos.springvote22nd.global.config.exception.GlobalErrorCode; +import com.ceos.springvote22nd.global.config.exception.ResultCode; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class CommonResponse { + @Schema(description = "http Status code") + private final Integer statusCode; + @Schema(description = "http Status message") + private final String message; + + @Schema(description = "데이터") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private T data; + + // 오류 등 데이터 없는 경우 사용 + public CommonResponse(ResultCode resultCode) { + this.statusCode = resultCode.getCode(); + this.message = resultCode.getMessage(); + } + + // 성공 시 일반적인 생성자 + public CommonResponse(ResultCode resultCode, T data) { + this.statusCode = resultCode.getCode(); + this.message = resultCode.getMessage(); + this.data = data; + } + + // 이거 호출로 성공 생성자 자동 호출, 데이터 담아서 반환됨 + public static CommonResponse success(T data) { + return new CommonResponse<>(GlobalErrorCode.SUCCESS, data); + } + + +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/user/controller/UserController.java b/src/main/java/com/ceos/springvote22nd/domain/user/controller/UserController.java new file mode 100644 index 0000000..eacf778 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/user/controller/UserController.java @@ -0,0 +1,38 @@ +package com.ceos.springvote22nd.domain.user.controller; + +import com.ceos.springvote22nd.domain.common.dto.response.CommonResponse; +import com.ceos.springvote22nd.domain.user.dto.request.SignUpRequestDTO; +import com.ceos.springvote22nd.domain.user.dto.response.SignUpResponseDTO; +import com.ceos.springvote22nd.domain.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor +@Tag(name = "회원 API", description = "회원 관련 엔드포인트") +public class UserController { + + private final UserService userService; + + /* + 회원가입 API 엔드포인트 + */ + @Operation( + summary = "회원가입", + description = "사용자의 정보를 입력받아 검증한 후 DB에 저장한다." + ) + @PostMapping("/signup") + public ResponseEntity> signUp(@Valid @RequestBody SignUpRequestDTO request) { + SignUpResponseDTO response = userService.signUp(request); + + return ResponseEntity.ok(CommonResponse.success(response)); + } +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/user/dto/request/SignUpRequestDTO.java b/src/main/java/com/ceos/springvote22nd/domain/user/dto/request/SignUpRequestDTO.java new file mode 100644 index 0000000..ae9e999 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/user/dto/request/SignUpRequestDTO.java @@ -0,0 +1,43 @@ +package com.ceos.springvote22nd.domain.user.dto.request; + +import com.ceos.springvote22nd.entity.enums.Part; +import com.ceos.springvote22nd.entity.enums.Team; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class SignUpRequestDTO { + + @NotBlank(message = "아이디를 입력해주세요.") + private String username; + + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; + + @NotBlank(message = "비밀번호를 입력해주세요.") + private String password; + + @NotBlank(message = "비밀번호 확인을 입력해주세요.") + private String passwordConfirm; + + @NotNull(message = "본인이 속한 팀을 선택해주세요.") + @Schema(description = "소속 팀", implementation = Team.class) + private Team team; + + @NotNull(message = "본인이 속한 파트를 선택해주세요.") + @Schema(description = "소속 파트", implementation = Part.class) + private Part part; + + // 비밀번호 일치 검증 메서드 + @Schema(hidden = true) + @JsonIgnore + public boolean isPasswordMatch() { + return password != null && password.equals(passwordConfirm); + } +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/user/dto/response/SignUpResponseDTO.java b/src/main/java/com/ceos/springvote22nd/domain/user/dto/response/SignUpResponseDTO.java new file mode 100644 index 0000000..0c3e476 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/user/dto/response/SignUpResponseDTO.java @@ -0,0 +1,23 @@ +package com.ceos.springvote22nd.domain.user.dto.response; + +import com.ceos.springvote22nd.entity.enums.Part; +import com.ceos.springvote22nd.entity.enums.Team; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class SignUpResponseDTO { + + private Long userId; + private String username; + private Team team; + private Part part; + private String email; + + private LocalDateTime createdAt; + + +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/user/exception/UserErrorCode.java b/src/main/java/com/ceos/springvote22nd/domain/user/exception/UserErrorCode.java new file mode 100644 index 0000000..2b46f7b --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/user/exception/UserErrorCode.java @@ -0,0 +1,23 @@ +package com.ceos.springvote22nd.domain.user.exception; + +import com.ceos.springvote22nd.global.config.exception.ResultCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ResultCode { + + // User 관련 에러 + INVALID_USERNAME(HttpStatus.NOT_FOUND, 1101, "아이디가 일치하지 않습니다."), + INVALID_PASSWORD(HttpStatus.CONFLICT, 1102, "비밀번호가 일치하지 않습니다."), + INVALID_EMAIL(HttpStatus.CONFLICT, 1103, "이메일이 일치하지 않습니다."), + DUPLICATE_USERNAME(HttpStatus.CONFLICT, 1104, "이미 사용 중인 아이디입니다."), + DUPLICATE_EMAIL(HttpStatus.CONFLICT, 1105, "이미 사용 중인 이메일입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, 1106, "존재하지 않는 회원입니다."); + + private final HttpStatus status; + private final int code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/user/repository/UserRepository.java b/src/main/java/com/ceos/springvote22nd/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..0db5f56 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/user/repository/UserRepository.java @@ -0,0 +1,34 @@ +package com.ceos.springvote22nd.domain.user.repository; + +import com.ceos.springvote22nd.domain.vote.dto.response.CandidateResponseDTO; +import com.ceos.springvote22nd.entity.User; +import com.ceos.springvote22nd.entity.enums.Part; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.lang.reflect.ParameterizedType; +import java.util.List; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + boolean existsByEmail(String email); + boolean existsByUsername(String username); + Optional findByEmail(String email); + List findAllByPart(Part part); + + // 파트별 후보자, 득표수 한번에 내림차순으로 조회 + @Query("SELECT new com.ceos.springvote22nd.domain.vote.dto.response.CandidateResponseDTO(" + + "u.id, " + + "u.part, " + + "u.username, " + + "u.team, " + + "COUNT(v)) " + + "FROM User u " + + "LEFT JOIN PartLeaderVote v ON v.candidate = u " + + "WHERE u.part = :part " + + "GROUP BY u.id, u.part, u.username, u.team " + + "ORDER BY COUNT(v) DESC") + List findAllCandidatesWithVoteCount(@Param("part") Part part); +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/user/service/UserService.java b/src/main/java/com/ceos/springvote22nd/domain/user/service/UserService.java new file mode 100644 index 0000000..bccca30 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/user/service/UserService.java @@ -0,0 +1,77 @@ +package com.ceos.springvote22nd.domain.user.service; + +import com.ceos.springvote22nd.domain.auth.exception.AuthErrorCode; +import com.ceos.springvote22nd.domain.user.dto.request.SignUpRequestDTO; +import com.ceos.springvote22nd.domain.user.dto.response.SignUpResponseDTO; +import com.ceos.springvote22nd.domain.user.exception.UserErrorCode; +import com.ceos.springvote22nd.domain.user.repository.UserRepository; +import com.ceos.springvote22nd.entity.User; +import com.ceos.springvote22nd.global.config.exception.GlobalException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public SignUpResponseDTO signUp(SignUpRequestDTO request) { + // 비밀번호 일치 검증 + if (!request.isPasswordMatch()) { + throw new GlobalException(UserErrorCode.INVALID_PASSWORD); + } + + // 이메일 중복 검사 + validateDuplicateEmail(request.getEmail()); + + // 아이디 중복 검사 + validateDuplicateUsername(request.getUsername()); + + + // User 엔티티 생성 + User user = User.builder() + .username(request.getUsername()) + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .part(request.getPart()) + .team(request.getTeam()) + .build(); + + // 저장 + User savedUser = userRepository.save(user); + + // 응답 생성 + return SignUpResponseDTO.builder() + .userId(savedUser.getId()) + .email(savedUser.getEmail()) + .username(savedUser.getUsername()) + .part(savedUser.getPart()) + .team(savedUser.getTeam()) + .createdAt(savedUser.getCreatedAt()) + .build(); + } + + // 관련 메서드 + + private void validateDuplicateEmail(String email) { + if (userRepository.existsByEmail(email)) { + throw new GlobalException(UserErrorCode.DUPLICATE_EMAIL); + } + } + + private void validateDuplicateUsername(String username) { + if (userRepository.existsByUsername(username)) { + throw new GlobalException(UserErrorCode.DUPLICATE_USERNAME); + } + } + + +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/controller/PartLeaderVoteController.java b/src/main/java/com/ceos/springvote22nd/domain/vote/controller/PartLeaderVoteController.java new file mode 100644 index 0000000..2cec88f --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/controller/PartLeaderVoteController.java @@ -0,0 +1,46 @@ +package com.ceos.springvote22nd.domain.vote.controller; + +import com.ceos.springvote22nd.domain.common.dto.response.CommonResponse; +import com.ceos.springvote22nd.domain.vote.dto.request.VoteRequestDTO; +import com.ceos.springvote22nd.domain.vote.dto.response.CandidateResponseDTO; +import com.ceos.springvote22nd.domain.vote.service.PartLeaderVoteService; +import com.ceos.springvote22nd.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/votes/part-leader") +@RequiredArgsConstructor +@Tag(name = "파트장 투표 API", description = "파트장 투표 관련 기능") +public class PartLeaderVoteController { + + private final PartLeaderVoteService partLeaderVoteService; + + @Operation(summary = "후보 목록 조회", description = "내 파트의 후보자들과 현재 득표수를 조회합니다.") + @GetMapping + public ResponseEntity>> getCandidates( + @AuthenticationPrincipal Long userId + ) { + List candidates = partLeaderVoteService.getPartLeaderCandidates(userId); + return ResponseEntity.ok(CommonResponse.success(candidates)); + } + + + @Operation(summary = "투표하기", description = "특정 후보에게 투표합니다.") + @PostMapping + public ResponseEntity> vote( + @AuthenticationPrincipal Long userId, + @RequestBody VoteRequestDTO request + ) { + partLeaderVoteService.votePartLeader(userId, request); + + return ResponseEntity.ok(CommonResponse.success(null)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/controller/TeamVoteController.java b/src/main/java/com/ceos/springvote22nd/domain/vote/controller/TeamVoteController.java new file mode 100644 index 0000000..ca24178 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/controller/TeamVoteController.java @@ -0,0 +1,41 @@ +package com.ceos.springvote22nd.domain.vote.controller; + +import com.ceos.springvote22nd.domain.common.dto.response.CommonResponse; +import com.ceos.springvote22nd.domain.vote.dto.request.TeamVoteRequestDTO; +import com.ceos.springvote22nd.domain.vote.dto.response.TeamVoteResponseDTO; +import com.ceos.springvote22nd.domain.vote.service.TeamVoteService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/votes/team") +@RequiredArgsConstructor +@Tag(name = "팀 투표 API", description = "팀 투표 관련 기능") +public class TeamVoteController { + + private final TeamVoteService teamVoteService; + + @Operation(summary = "팀 투표 하기", description = "특정 팀에게 투표합니다.") + @PostMapping + public ResponseEntity> voteTeam( + @AuthenticationPrincipal Long userId, + @RequestBody TeamVoteRequestDTO request + ) { + teamVoteService.voteTeam(userId, request); + return ResponseEntity.ok(CommonResponse.success(null)); + } + + // 팀 투표 순위 조회 + @Operation(summary = "팀 투표 순위 조회", description = "모든 팀의 득표수를 내림차순으로 조회합니다.") + @GetMapping + public ResponseEntity>> getTeamVotes() { + List result = teamVoteService.getTeamVoteCounts(); + return ResponseEntity.ok(CommonResponse.success(result)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/dto/request/TeamVoteRequestDTO.java b/src/main/java/com/ceos/springvote22nd/domain/vote/dto/request/TeamVoteRequestDTO.java new file mode 100644 index 0000000..79ae3de --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/dto/request/TeamVoteRequestDTO.java @@ -0,0 +1,16 @@ +package com.ceos.springvote22nd.domain.vote.dto.request; + +import com.ceos.springvote22nd.entity.enums.Team; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TeamVoteRequestDTO { + + @Schema(description = "투표할 팀 이름", example = "MENUAL") + @NotNull(message = "팀을 선택해주세요.") + private Team team; +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/dto/request/VoteRequestDTO.java b/src/main/java/com/ceos/springvote22nd/domain/vote/dto/request/VoteRequestDTO.java new file mode 100644 index 0000000..dc3d1ef --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/dto/request/VoteRequestDTO.java @@ -0,0 +1,11 @@ +package com.ceos.springvote22nd.domain.vote.dto.request; + +import lombok.Getter; + +@Getter +public class VoteRequestDTO { + + // 파트장 투표 시 후보자 ID + private Long candidateId; + +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/dto/response/CandidateResponseDTO.java b/src/main/java/com/ceos/springvote22nd/domain/vote/dto/response/CandidateResponseDTO.java new file mode 100644 index 0000000..b7a07fc --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/dto/response/CandidateResponseDTO.java @@ -0,0 +1,24 @@ +package com.ceos.springvote22nd.domain.vote.dto.response; + +import com.ceos.springvote22nd.entity.enums.Part; +import com.ceos.springvote22nd.entity.enums.Team; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CandidateResponseDTO { + private Long id; + private Part part; + private String name; + private String teamName; + private Long voteCount; + + public CandidateResponseDTO(Long id, Part part, String name, Team team, Long voteCount) { + this.id = id; + this.part = part; + this.name = name; + this.teamName = team.toString();; + this.voteCount = voteCount; + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/dto/response/TeamVoteResponseDTO.java b/src/main/java/com/ceos/springvote22nd/domain/vote/dto/response/TeamVoteResponseDTO.java new file mode 100644 index 0000000..b733eab --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/dto/response/TeamVoteResponseDTO.java @@ -0,0 +1,17 @@ +package com.ceos.springvote22nd.domain.vote.dto.response; + +import com.ceos.springvote22nd.entity.enums.Team; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TeamVoteResponseDTO { + private String teamName; + private Long voteCount; + + public TeamVoteResponseDTO(Team team, Long voteCount) { + this.teamName = team.toString(); + this.voteCount = voteCount; + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/exception/VoteErrorCode.java b/src/main/java/com/ceos/springvote22nd/domain/vote/exception/VoteErrorCode.java new file mode 100644 index 0000000..8980c6b --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/exception/VoteErrorCode.java @@ -0,0 +1,21 @@ +package com.ceos.springvote22nd.domain.vote.exception; + +import com.ceos.springvote22nd.global.config.exception.ResultCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum VoteErrorCode implements ResultCode { + + // Vote 관련 에러 + INVALID_PART(HttpStatus.NOT_FOUND, 2101, "파트가 일치해야합니다."), + ALREADY_VOTED(HttpStatus.CONFLICT, 2102, "한 사람은 한번만 투표할 수 있습니다."), + INVALID_TEAM_VOTE(HttpStatus.BAD_REQUEST, 2103, "자신이 속한 팀에는 투표할 수 없습니다."); + + + private final HttpStatus status; + private final int code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/repository/PartLeaderVoteRepository.java b/src/main/java/com/ceos/springvote22nd/domain/vote/repository/PartLeaderVoteRepository.java new file mode 100644 index 0000000..4eefeb9 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/repository/PartLeaderVoteRepository.java @@ -0,0 +1,21 @@ +package com.ceos.springvote22nd.domain.vote.repository; + +import com.ceos.springvote22nd.domain.vote.dto.response.CandidateResponseDTO; +import com.ceos.springvote22nd.entity.PartLeaderVote; +import com.ceos.springvote22nd.entity.User; +import com.ceos.springvote22nd.entity.enums.Part; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface PartLeaderVoteRepository extends JpaRepository { + + // 후보자가 받은 표 세는 용도 + int countByCandidate(User candidate); + + // 이미 투표했는지 여부 확인하는 용도 + boolean existsByVoter(User voter); + +} diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/repository/TeamVoteRepository.java b/src/main/java/com/ceos/springvote22nd/domain/vote/repository/TeamVoteRepository.java new file mode 100644 index 0000000..0629e12 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/repository/TeamVoteRepository.java @@ -0,0 +1,20 @@ +package com.ceos.springvote22nd.domain.vote.repository; + +import com.ceos.springvote22nd.domain.vote.dto.response.TeamVoteResponseDTO; +import com.ceos.springvote22nd.entity.TeamVote; +import com.ceos.springvote22nd.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface TeamVoteRepository extends JpaRepository { + // 이미 투표했는지 검사하기 위함 + boolean existsByVoter(User voter); + + // 팀별 득표수 조회 (투표가 있는 팀만 조회됨) + @Query("SELECT new com.ceos.springvote22nd.domain.vote.dto.response.TeamVoteResponseDTO(v.team, COUNT(v)) " + + "FROM TeamVote v " + + "GROUP BY v.team") + List findVoteCounts(); +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/service/PartLeaderVoteService.java b/src/main/java/com/ceos/springvote22nd/domain/vote/service/PartLeaderVoteService.java new file mode 100644 index 0000000..60bbfdf --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/service/PartLeaderVoteService.java @@ -0,0 +1,71 @@ +package com.ceos.springvote22nd.domain.vote.service; + +import com.ceos.springvote22nd.domain.user.exception.UserErrorCode; +import com.ceos.springvote22nd.domain.user.repository.UserRepository; +import com.ceos.springvote22nd.domain.vote.dto.request.VoteRequestDTO; +import com.ceos.springvote22nd.domain.vote.exception.VoteErrorCode; +import com.ceos.springvote22nd.domain.vote.repository.PartLeaderVoteRepository; +import com.ceos.springvote22nd.domain.vote.dto.response.CandidateResponseDTO; +import com.ceos.springvote22nd.entity.PartLeaderVote; +import com.ceos.springvote22nd.entity.User; +import com.ceos.springvote22nd.entity.enums.Part; +import com.ceos.springvote22nd.global.config.exception.GlobalException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PartLeaderVoteService { + + private final UserRepository userRepository; + private final PartLeaderVoteRepository partLeaderVoteRepository; + + /** + * 파트장 후보 리스트 조회 + */ + public List getPartLeaderCandidates(Long userId) { + + // userId로 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(UserErrorCode.USER_NOT_FOUND)); + + // 자신이 해당하는 파트 + Part myPart = user.getPart(); + + return userRepository.findAllCandidatesWithVoteCount(myPart); + } + + public void votePartLeader(Long userId, VoteRequestDTO request) { + + // 투표자 조회 + User voter = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(UserErrorCode.USER_NOT_FOUND)); + + // 후보자 조회 + User candidate = userRepository.findById(request.getCandidateId()) + .orElseThrow(() -> new GlobalException(UserErrorCode.USER_NOT_FOUND)); + + // 투표자와 후보자의 파트가 같아야 함 + if (voter.getPart() != candidate.getPart()) { + throw new GlobalException(VoteErrorCode.INVALID_PART); + } + + // 한 사람은 한번만 투표할 수 있음 + if (partLeaderVoteRepository.existsByVoter(voter)) { + throw new GlobalException(VoteErrorCode.ALREADY_VOTED); + } + + // 투표 엔티티 생성 + PartLeaderVote vote = PartLeaderVote.builder() + .voter(voter) + .candidate(candidate) + .build(); + // 저장 + partLeaderVoteRepository.save(vote); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/domain/vote/service/TeamVoteService.java b/src/main/java/com/ceos/springvote22nd/domain/vote/service/TeamVoteService.java new file mode 100644 index 0000000..d82b46e --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/domain/vote/service/TeamVoteService.java @@ -0,0 +1,77 @@ +package com.ceos.springvote22nd.domain.vote.service; + +import com.ceos.springvote22nd.domain.user.exception.UserErrorCode; +import com.ceos.springvote22nd.domain.user.repository.UserRepository; +import com.ceos.springvote22nd.domain.vote.dto.request.TeamVoteRequestDTO; +import com.ceos.springvote22nd.domain.vote.dto.response.TeamVoteResponseDTO; +import com.ceos.springvote22nd.domain.vote.exception.VoteErrorCode; +import com.ceos.springvote22nd.domain.vote.repository.TeamVoteRepository; +import com.ceos.springvote22nd.entity.TeamVote; +import com.ceos.springvote22nd.entity.User; +import com.ceos.springvote22nd.entity.enums.Team; +import com.ceos.springvote22nd.global.config.exception.GlobalException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamVoteService { + + private final UserRepository userRepository; + private final TeamVoteRepository teamVoteRepository; + + @Transactional + public void voteTeam(Long userId, TeamVoteRequestDTO request) { + // 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(UserErrorCode.USER_NOT_FOUND)); + + // 중복 투표 확인 + if (teamVoteRepository.existsByVoter(user)) { + throw new GlobalException(VoteErrorCode.ALREADY_VOTED); + } + + // 본인 팀에는 투표 불가 + if (user.getTeam() == request.getTeam()) { + throw new GlobalException(VoteErrorCode.INVALID_TEAM_VOTE); + } + + // 저장 + TeamVote vote = TeamVote.builder() + .voter(user) + .team(request.getTeam()) + .build(); + + teamVoteRepository.save(vote); + } + + // TODO: 리팩토링 필요 + public List getTeamVoteCounts() { + // DB에서 투표가 있는 팀들의 집계 데이터를 가져옴 + List voteCounts = teamVoteRepository.findVoteCounts(); + + // Team Enum에 있는 모든 팀을 순회하며 초기화 + Map voteCountMap = new HashMap<>(); + for (Team team : Team.values()) { + voteCountMap.put(team.toString(), 0L); + } + + // DB에서 가져온 값을 덮어씀 + for (TeamVoteResponseDTO dto : voteCounts) { + voteCountMap.put(dto.getTeamName(), dto.getVoteCount()); + } + + // Map을 다시 List로 변환하고, 득표수 내림차순 정렬 + return voteCountMap.entrySet().stream() + .map(entry -> new TeamVoteResponseDTO(Team.valueOf(entry.getKey()), entry.getValue())) + .sorted((a, b) -> Long.compare(b.getVoteCount(), a.getVoteCount())) // 득표수 많은 순 + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/entity/BaseEntity.java b/src/main/java/com/ceos/springvote22nd/entity/BaseEntity.java new file mode 100644 index 0000000..99730ec --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/entity/BaseEntity.java @@ -0,0 +1,24 @@ +package com.ceos.springvote22nd.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/entity/PartLeaderVote.java b/src/main/java/com/ceos/springvote22nd/entity/PartLeaderVote.java new file mode 100644 index 0000000..17744d8 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/entity/PartLeaderVote.java @@ -0,0 +1,32 @@ +package com.ceos.springvote22nd.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class PartLeaderVote extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 투표하는 사람 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voter_id", nullable = false) + private User voter; + + // 투표 대상 파트장 후보 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "candidate_id", nullable = false) + private User candidate; + + @Builder + public PartLeaderVote(User voter, User candidate) { + this.voter = voter; + this.candidate = candidate; + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/entity/TeamVote.java b/src/main/java/com/ceos/springvote22nd/entity/TeamVote.java new file mode 100644 index 0000000..5ba54d9 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/entity/TeamVote.java @@ -0,0 +1,31 @@ +package com.ceos.springvote22nd.entity; + +import com.ceos.springvote22nd.entity.enums.Team; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamVote { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User voter; + + @Enumerated(EnumType.STRING) + private Team team; + + @Builder + public TeamVote(User voter, Team team) { + this.voter = voter; + this.team = team; + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/entity/User.java b/src/main/java/com/ceos/springvote22nd/entity/User.java new file mode 100644 index 0000000..82424a6 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/entity/User.java @@ -0,0 +1,39 @@ +package com.ceos.springvote22nd.entity; + +import com.ceos.springvote22nd.entity.enums.Part; +import com.ceos.springvote22nd.entity.enums.Team; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "users") +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String username; + + @Column(unique = true, nullable = false) + private String email; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Part part; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Team team; +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/entity/enums/Part.java b/src/main/java/com/ceos/springvote22nd/entity/enums/Part.java new file mode 100644 index 0000000..255984c --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/entity/enums/Part.java @@ -0,0 +1,17 @@ +package com.ceos.springvote22nd.entity.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +public enum Part { + @Schema(description = "기획") + PM, + + @Schema(description = "디자이너") + DESIGNER, + + @Schema(description = "프론트엔드") + FRONTEND, + + @Schema(description = "백엔드") + BACKEND +} diff --git a/src/main/java/com/ceos/springvote22nd/entity/enums/Team.java b/src/main/java/com/ceos/springvote22nd/entity/enums/Team.java new file mode 100644 index 0000000..20e3135 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/entity/enums/Team.java @@ -0,0 +1,20 @@ +package com.ceos.springvote22nd.entity.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +public enum Team { + @Schema(description = "DIGGINDIE") + DIGGINDIE, + + @Schema(description = "MODELLY") + MODELLY, + + @Schema(description = "CATCHUP") + CATCHUP, + + @Schema(description = "MENUAL") + MENUAL, + + @Schema(description = "STORIX") + STORIX +} diff --git a/src/main/java/com/ceos/springvote22nd/global/config/JpaConfig.java b/src/main/java/com/ceos/springvote22nd/global/config/JpaConfig.java new file mode 100644 index 0000000..69b4567 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/JpaConfig.java @@ -0,0 +1,9 @@ +package com.ceos.springvote22nd.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/src/main/java/com/ceos/springvote22nd/global/config/SecurityConfig.java b/src/main/java/com/ceos/springvote22nd/global/config/SecurityConfig.java new file mode 100644 index 0000000..5217bbc --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/SecurityConfig.java @@ -0,0 +1,82 @@ +package com.ceos.springvote22nd.global.config; + +import com.ceos.springvote22nd.global.config.jwt.JwtAuthenticationFilter; +import com.ceos.springvote22nd.global.config.jwt.JwtExceptionFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtExceptionFilter jwtExceptionFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/", + "/api/auth/**", + "/api/user/signup", + + // Swagger + "/v3/api-docs", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/swagger-resources/**", + "/webjars/**", + "/configuration/**" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); + + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedOriginPattern("*"); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(List.of("*")); + configuration.setExposedHeaders(List.of("Authorization", "Content-Type")); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/global/config/SwaggerConfig.java b/src/main/java/com/ceos/springvote22nd/global/config/SwaggerConfig.java new file mode 100644 index 0000000..cd6dc71 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/SwaggerConfig.java @@ -0,0 +1,44 @@ +package com.ceos.springvote22nd.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 io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .servers(List.of( + new Server() + .url("http://ec2-52-79-241-109.ap-northeast-2.compute.amazonaws.com") + .description("Production Server"), + new Server() + .url("http://localhost:8080") + .description("Local Server") + )) + .components(new Components() + .addSecuritySchemes("Authorization", new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .in(SecurityScheme.In.HEADER) + .bearerFormat("JWT") + ) + ) + .addSecurityItem(new SecurityRequirement().addList("Authorization")) + .info(new Info() + .title("CEOS VOTE API") + .description("CEOS 투표시스템 API 명세서") + .version("1.0.0") + ); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/global/config/exception/GlobalErrorCode.java b/src/main/java/com/ceos/springvote22nd/global/config/exception/GlobalErrorCode.java new file mode 100644 index 0000000..d126d8e --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/exception/GlobalErrorCode.java @@ -0,0 +1,16 @@ +package com.ceos.springvote22nd.global.config.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum GlobalErrorCode implements ResultCode{ + //global + SUCCESS(HttpStatus.OK, 0, "정상 처리 되었습니다."); + + private final HttpStatus status; + private final int code; + private final String message; +} diff --git a/src/main/java/com/ceos/springvote22nd/global/config/exception/GlobalException.java b/src/main/java/com/ceos/springvote22nd/global/config/exception/GlobalException.java new file mode 100644 index 0000000..8d134d7 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/exception/GlobalException.java @@ -0,0 +1,10 @@ +package com.ceos.springvote22nd.global.config.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class GlobalException extends RuntimeException { + private final ResultCode resultCode; +} diff --git a/src/main/java/com/ceos/springvote22nd/global/config/exception/GlobalExceptionHandler.java b/src/main/java/com/ceos/springvote22nd/global/config/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..7b170c9 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/exception/GlobalExceptionHandler.java @@ -0,0 +1,18 @@ +package com.ceos.springvote22nd.global.config.exception; + +import com.ceos.springvote22nd.domain.common.dto.response.CommonResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + //GlobalException 발생 시 반환 형태 + @ExceptionHandler(GlobalException.class) + public ResponseEntity> handleException(GlobalException e){ + return ResponseEntity.status(e.getResultCode().getStatus()) + .body(new CommonResponse<>(e.getResultCode())); + } +} diff --git a/src/main/java/com/ceos/springvote22nd/global/config/exception/ResultCode.java b/src/main/java/com/ceos/springvote22nd/global/config/exception/ResultCode.java new file mode 100644 index 0000000..27fd238 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/exception/ResultCode.java @@ -0,0 +1,9 @@ +package com.ceos.springvote22nd.global.config.exception; + +import org.springframework.http.HttpStatus; + +public interface ResultCode { + HttpStatus getStatus(); + int getCode(); + String getMessage(); +} diff --git a/src/main/java/com/ceos/springvote22nd/global/config/jwt/CookieUtil.java b/src/main/java/com/ceos/springvote22nd/global/config/jwt/CookieUtil.java new file mode 100644 index 0000000..82cf077 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/jwt/CookieUtil.java @@ -0,0 +1,48 @@ +package com.ceos.springvote22nd.global.config.jwt; + +import com.ceos.springvote22nd.domain.auth.exception.AuthErrorCode; +import com.ceos.springvote22nd.global.config.exception.GlobalException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CookieUtil { + + private final JwtProvider jwtProvider; + + private static final String ACCESS_TOKEN_NAME = "accessToken"; + private static final String REFRESH_TOKEN_NAME = "refreshToken"; + + public void addAccessTokenCookie(HttpServletResponse response, String token) { + Cookie cookie = createCookie( + ACCESS_TOKEN_NAME, + token, + (int) (jwtProvider.getAccessTokenValidity() / 1000), + "/" + ); + response.addCookie(cookie); + } + + public void deleteAccessTokenCookie(HttpServletResponse response) { + Cookie cookie = createCookie(ACCESS_TOKEN_NAME, null, 0, "/"); + response.addCookie(cookie); + } + + + + private Cookie createCookie(String name, String value, int maxAge, String path) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); +// cookie.setSecure(true); // HTTPS 환경에서만 전송 + cookie.setPath(path); + cookie.setMaxAge(maxAge); + // cookie.setSameSite("Strict"); // Spring Boot 3.x에서는 별도 설정 필요 + return cookie; + } +} diff --git a/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..1f5e67b --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,85 @@ +package com.ceos.springvote22nd.global.config.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final JwtValidator jwtValidator; + + // + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain + ) throws ServletException, IOException { + + // 클라이언트에서 accessToken 쿠키 추출 + String accessToken = getTokenFromCookie(request, "accessToken"); + + + if (accessToken != null) { + try { + // accessToken의 유효성 및 서명 검증 + if (jwtValidator.validateToken(accessToken)) { + // 토큰에 저장된 토큰 type 확인 + String tokenType = jwtValidator.getTokenType(accessToken); + // access 토큰인 경우에만 인증 처리 + if ("access".equals(tokenType)) { + Long userId = jwtValidator.getUserIdFromToken(accessToken); + // 인증 객체를 생성해 SecurityContext에 저장 + // 이후 컨트롤러에서 @AuthenticationPrincipal 등으로 접근 가능 + setAuthentication(request, userId); + } + } + } catch (Exception e) { + log.error("JWT 인증 실패: {}", e.getMessage()); + request.setAttribute("exception", e); + } + } + + // 다음 필터로 전달 + filterChain.doFilter(request, response); + } + + // SecurityContext에 인증 정보를 등록하는 메서드 + private void setAuthentication(HttpServletRequest request, Long userId) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userId, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + // Cookie에서 Token을 꺼내는 메서드 + private String getTokenFromCookie(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookieName.equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtExceptionFilter.java b/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtExceptionFilter.java new file mode 100644 index 0000000..751ef41 --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtExceptionFilter.java @@ -0,0 +1,63 @@ +package com.ceos.springvote22nd.global.config.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtExceptionFilter extends OncePerRequestFilter { + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + try { + filterChain.doFilter(request, response); + } catch (ExpiredJwtException e) { + setErrorResponse(response, "만료된 토큰입니다.", HttpServletResponse.SC_UNAUTHORIZED); + } catch (MalformedJwtException | UnsupportedJwtException e) { + setErrorResponse(response, "유효하지 않은 토큰입니다.", HttpServletResponse.SC_UNAUTHORIZED); + } catch (SecurityException e) { + setErrorResponse(response, "잘못된 JWT 서명입니다.", HttpServletResponse.SC_UNAUTHORIZED); + } catch (Exception e) { + setErrorResponse(response, "인증 처리 중 오류가 발생했습니다.", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + + private void setErrorResponse(HttpServletResponse response, String message, int status) { + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + Map errorResponse = new HashMap<>(); + errorResponse.put("success", false); + errorResponse.put("message", message); + errorResponse.put("timestamp", System.currentTimeMillis()); + + try { + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } catch (IOException e) { + log.error("응답 작성 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtProvider.java b/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtProvider.java new file mode 100644 index 0000000..f8d34bf --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtProvider.java @@ -0,0 +1,55 @@ +package com.ceos.springvote22nd.global.config.jwt; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +public class JwtProvider { + + @Getter + @Value("${jwt.access-token-validity}") + private long accessTokenValidity; + + private final SecretKey key; + + public JwtProvider(@Value("${jwt.secret}") String secret) { + // 최소 32바이트(256비트) 검증 + if (secret.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException( + "JWT secret key must be at least 32 bytes long (256 bits, HS256 standard)" + ); + } + this.key = Keys.hmacShaKeyFor( + secret.getBytes(StandardCharsets.UTF_8) + ); + } + + // Access Token 생성 + public String createAccessToken(Long userId, String email) { + Date now = new Date(); + Date validity = new Date(now.getTime() + accessTokenValidity); + + return Jwts.builder() + .subject(userId.toString()) + .claim("email", email) + .claim("type", "access") + .issuedAt(now) + .expiration(validity) + .signWith(key) + .compact(); + } + + SecretKey getKey() { + return this.key; + } + +} diff --git a/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtValidator.java b/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtValidator.java new file mode 100644 index 0000000..ff058de --- /dev/null +++ b/src/main/java/com/ceos/springvote22nd/global/config/jwt/JwtValidator.java @@ -0,0 +1,65 @@ +package com.ceos.springvote22nd.global.config.jwt; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtValidator { + + private final JwtProvider jwtProvider; + + private SecretKey getKey() { + return jwtProvider.getKey(); + } + + // 토큰 검증 + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(getKey()) + .build() + .parseSignedClaims(token); + return true; + + } catch (ExpiredJwtException | UnsupportedJwtException | + MalformedJwtException | SecurityException | + IllegalArgumentException e) { + + log.warn("JWT 검증 실패: {}", e.getMessage()); + return false; + } + } + + // 사용자 ID 추출 + public Long getUserIdFromToken(String token) { + return Long.parseLong( + Jwts.parser() + .verifyWith(getKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject() + ); + } + + // 토큰 타입 추출 + public String getTokenType(String token) { + return Jwts.parser() + .verifyWith(getKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .get("type", String.class); + } + + +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..6227a9f --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,26 @@ +spring: + datasource: + url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME} + driver-class-name: org.postgresql.Driver + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + +springdoc: + swagger-ui: + path: /swagger-ui.html + enabled: true + api-docs: + path: /v3/api-docs + enabled: true + +jwt: + secret: ${JWT_SECRET} + access-token-validity: 86400000 # 1일 diff --git a/src/test/java/com/ceos/springvote22nd/SpringVote22ndApplicationTests.java b/src/test/java/com/ceos/springvote22nd/SpringVote22ndApplicationTests.java new file mode 100644 index 0000000..accae3e --- /dev/null +++ b/src/test/java/com/ceos/springvote22nd/SpringVote22ndApplicationTests.java @@ -0,0 +1,13 @@ +package com.ceos.springvote22nd; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SpringVote22ndApplicationTests { + + @Test + void contextLoads() { + } + +}