From 0d8a68a8c812b7502c855c596278f46ea07c7832 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Fri, 28 Nov 2025 20:25:01 +0900 Subject: [PATCH 01/52] init: initial settings --- .gitattributes | 3 + .gitignore | 37 +++ build.gradle | 40 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++++++++++++ gradlew.bat | 93 +++++++ settings.gradle | 1 + .../com/diggindie/vote/VoteApplication.java | 13 + src/main/resources/application.yml | 3 + .../diggindie/vote/VoteApplicationTests.java | 13 + 11 files changed, 458 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/diggindie/vote/VoteApplication.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/diggindie/vote/VoteApplicationTests.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +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/build.gradle b/build.gradle new file mode 100644 index 0000000..41e1669 --- /dev/null +++ b/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.0' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.diggindie' +version = '0.0.1-SNAPSHOT' +description = 'voting service for ceos 22nd' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 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�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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /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-9.2.1-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..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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 + + + +# 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" ) + + 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" \ + -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..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@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 + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..bfce6e0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'vote' diff --git a/src/main/java/com/diggindie/vote/VoteApplication.java b/src/main/java/com/diggindie/vote/VoteApplication.java new file mode 100644 index 0000000..0907b16 --- /dev/null +++ b/src/main/java/com/diggindie/vote/VoteApplication.java @@ -0,0 +1,13 @@ +package com.diggindie.vote; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class VoteApplication { + + public static void main(String[] args) { + SpringApplication.run(VoteApplication.class, args); + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..3761c62 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + application: + name: vote diff --git a/src/test/java/com/diggindie/vote/VoteApplicationTests.java b/src/test/java/com/diggindie/vote/VoteApplicationTests.java new file mode 100644 index 0000000..82b8211 --- /dev/null +++ b/src/test/java/com/diggindie/vote/VoteApplicationTests.java @@ -0,0 +1,13 @@ +package com.diggindie.vote; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class VoteApplicationTests { + + @Test + void contextLoads() { + } + +} From 454150b4e5609a8535338ce4e591f8a90426294f Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Fri, 28 Nov 2025 20:27:37 +0900 Subject: [PATCH 02/52] chore: adding issue template --- .github/ISSUE_TEMPLATE/feature_request.md | 26 +++++++++++++++ .github/ISSUE_TEMPLATE/troubleshooting.md | 40 +++++++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 32 ++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/troubleshooting.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8f7f199 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: ✨ Feature Request +about: 새로운 기능을 제안하세요. +title: "[FEAT] <기능 요약>" +labels: enhancement +assignees: '' +--- + + + + + + + +## 🚀 기능 설명 + + + +## 🏆 작업 목록 + +- + +## 🔗 참고 자료 + diff --git a/.github/ISSUE_TEMPLATE/troubleshooting.md b/.github/ISSUE_TEMPLATE/troubleshooting.md new file mode 100644 index 0000000..7e142e4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/troubleshooting.md @@ -0,0 +1,40 @@ +--- +name: ✨ Trouble Shooting +about: 문제를 해결했던 기록을 남겨봐요. +title: "[Trouble Shooting] <오류 요약>" +labels: bug +assignees: '' +--- + + + + + + + +## 📌 이슈 설명 + + + +## 🚀 Description +- [ ] +- [ ] +- [ ] + +## ⏰ 문제 해결을 위해 시도한 점 + + +## ❄️ 주의할 점 + + + +## 🔗 참고 자료 + + + +## ✅ TODO +- [ ] label 확인 +- [ ] assigness 확인 \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8358624 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ + + + + +## 🛰️ Issue Number + +close # + + +## 🪐 작업 내용 + + + + +## ⚠️ PR 특이 사항 + + + + +## 📚 Reference + + + + +### ✅ Check List +- [ ] 코드가 정상적으로 컴파일되나요? +- [ ] 포스트맨에서 결과값을 제대로 확인했나요? +- [ ] 리뷰어 설정을 지정했나요? +- [ ] merge할 브랜치의 위치를 확인했나요? +- [ ] Label을 지정했나요? From 61f623299485e209d1bc1736f64fde4d6a8f8955 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Tue, 16 Dec 2025 16:31:34 +0900 Subject: [PATCH 03/52] init: terraform initial settings --- infra/blue/main.tf | 0 infra/green/main.tf | 0 infra/main.tf | 0 infra/modules/ec2_instance/main.tf | 0 infra/modules/ec2_instance/outputs.tf | 0 infra/modules/ec2_instance/variables.tf | 0 infra/modules/ecs_cluster/main.tf | 0 infra/modules/ecs_cluster/outputs.tf | 0 infra/modules/ecs_cluster/variables.tf | 0 infra/modules/ecs_service/main.tf | 0 infra/modules/ecs_service/outputs.tf | 0 infra/modules/ecs_service/variables.tf | 0 infra/modules/rds/main.tf | 0 infra/modules/rds/outputs.tf | 0 infra/modules/rds/variables.tf | 0 infra/modules/vpc/main.tf | 0 infra/modules/vpc/outputs.tf | 0 infra/modules/vpc/variables.tf | 0 infra/terraform.tfvars | 0 infra/variables.tf | 0 20 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 infra/blue/main.tf create mode 100644 infra/green/main.tf create mode 100644 infra/main.tf create mode 100644 infra/modules/ec2_instance/main.tf create mode 100644 infra/modules/ec2_instance/outputs.tf create mode 100644 infra/modules/ec2_instance/variables.tf create mode 100644 infra/modules/ecs_cluster/main.tf create mode 100644 infra/modules/ecs_cluster/outputs.tf create mode 100644 infra/modules/ecs_cluster/variables.tf create mode 100644 infra/modules/ecs_service/main.tf create mode 100644 infra/modules/ecs_service/outputs.tf create mode 100644 infra/modules/ecs_service/variables.tf create mode 100644 infra/modules/rds/main.tf create mode 100644 infra/modules/rds/outputs.tf create mode 100644 infra/modules/rds/variables.tf create mode 100644 infra/modules/vpc/main.tf create mode 100644 infra/modules/vpc/outputs.tf create mode 100644 infra/modules/vpc/variables.tf create mode 100644 infra/terraform.tfvars create mode 100644 infra/variables.tf diff --git a/infra/blue/main.tf b/infra/blue/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/green/main.tf b/infra/green/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/ec2_instance/main.tf b/infra/modules/ec2_instance/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/ec2_instance/outputs.tf b/infra/modules/ec2_instance/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/ec2_instance/variables.tf b/infra/modules/ec2_instance/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/ecs_cluster/main.tf b/infra/modules/ecs_cluster/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/ecs_cluster/outputs.tf b/infra/modules/ecs_cluster/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/ecs_cluster/variables.tf b/infra/modules/ecs_cluster/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/ecs_service/main.tf b/infra/modules/ecs_service/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/ecs_service/outputs.tf b/infra/modules/ecs_service/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/ecs_service/variables.tf b/infra/modules/ecs_service/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/rds/main.tf b/infra/modules/rds/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/rds/outputs.tf b/infra/modules/rds/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/rds/variables.tf b/infra/modules/rds/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/vpc/main.tf b/infra/modules/vpc/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/vpc/outputs.tf b/infra/modules/vpc/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/modules/vpc/variables.tf b/infra/modules/vpc/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/infra/terraform.tfvars b/infra/terraform.tfvars new file mode 100644 index 0000000..e69de29 diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..e69de29 From c97d734b85e107e5c1fc355a40852f7230ca3fe2 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Tue, 16 Dec 2025 20:39:51 +0900 Subject: [PATCH 04/52] init: terraform initial settings --- .gitignore | 21 ++++ infra/blue/main.tf | 74 ++++++++++++ infra/green/main.tf | 74 ++++++++++++ infra/main.tf | 101 ++++++++++++++++ infra/modules/ec2_instance/main.tf | 69 +++++++++++ infra/modules/ec2_instance/outputs.tf | 21 ++++ infra/modules/ec2_instance/variables.tf | 32 +++++ infra/modules/ecs_cluster/main.tf | 28 +++++ infra/modules/ecs_cluster/outputs.tf | 16 +++ infra/modules/ecs_cluster/variables.tf | 11 ++ infra/modules/ecs_service/main.tf | 137 ++++++++++++++++++++++ infra/modules/ecs_service/outputs.tf | 21 ++++ infra/modules/ecs_service/variables.tf | 56 +++++++++ infra/modules/rds/main.tf | 76 ++++++++++++ infra/modules/rds/outputs.tf | 26 +++++ infra/modules/rds/variables.tf | 48 ++++++++ infra/modules/vpc/main.tf | 148 ++++++++++++++++++++++++ infra/modules/vpc/outputs.tf | 31 +++++ infra/modules/vpc/variables.tf | 17 +++ infra/terraform.tfvars | 18 +++ infra/variables.tf | 63 ++++++++++ 21 files changed, 1088 insertions(+) diff --git a/.gitignore b/.gitignore index c2065bc..983a5f7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,24 @@ out/ ### VS Code ### .vscode/ + +# Terraform +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# 민감 정보 +*.tfvars +*.tfvars.json +*.auto.tfvars +*.auto.tfvars.json + +# 로컬 백엔드 +terraform.tfstate.d/ \ No newline at end of file diff --git a/infra/blue/main.tf b/infra/blue/main.tf index e69de29..e24c484 100644 --- a/infra/blue/main.tf +++ b/infra/blue/main.tf @@ -0,0 +1,74 @@ +# blue/main.tf - Blue 환경 배포 설정 + +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" + + default_tags { + tags = { + Project = "spring-vote" + Environment = "blue" + ManagedBy = "Terraform" + } + } +} + +# 기존 인프라 참조 +data "aws_vpc" "main" { + tags = { + Name = "spring-vote-dev-vpc" + } +} + +data "aws_subnets" "public" { + filter { + name = "vpc-id" + values = [data.aws_vpc.main.id] + } + + filter { + name = "tag:Type" + values = ["Public"] + } +} + +data "aws_security_group" "web" { + vpc_id = data.aws_vpc.main.id + + filter { + name = "group-name" + values = ["spring-vote-dev-web-sg"] + } +} + +data "aws_ecs_cluster" "main" { + cluster_name = "spring-vote-dev-cluster" +} + +# Blue ECS Service +module "ecs_service_blue" { + source = "../modules/ecs_service" + + project_name = "spring-vote" + environment = "blue" + cluster_id = data.aws_ecs_cluster.main.id + subnet_ids = data.aws_subnets.public.ids + security_group_id = data.aws_security_group.web.id + container_image = "your-ecr-repo:blue" # TODO: 실제 이미지로 변경 + container_port = 8080 + desired_count = 1 +} + +output "blue_service_name" { + value = module.ecs_service_blue.service_name +} diff --git a/infra/green/main.tf b/infra/green/main.tf index e69de29..c10989d 100644 --- a/infra/green/main.tf +++ b/infra/green/main.tf @@ -0,0 +1,74 @@ +# green/main.tf - Green 환경 배포 설정 + +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" + + default_tags { + tags = { + Project = "spring-vote" + Environment = "green" + ManagedBy = "Terraform" + } + } +} + +# 기존 인프라 참조 (Remote State 또는 Data Source 사용) +data "aws_vpc" "main" { + tags = { + Name = "spring-vote-dev-vpc" + } +} + +data "aws_subnets" "public" { + filter { + name = "vpc-id" + values = [data.aws_vpc.main.id] + } + + filter { + name = "tag:Type" + values = ["Public"] + } +} + +data "aws_security_group" "web" { + vpc_id = data.aws_vpc.main.id + + filter { + name = "group-name" + values = ["spring-vote-dev-web-sg"] + } +} + +data "aws_ecs_cluster" "main" { + cluster_name = "spring-vote-dev-cluster" +} + +# Green ECS Service +module "ecs_service_green" { + source = "../modules/ecs_service" + + project_name = "spring-vote" + environment = "green" + cluster_id = data.aws_ecs_cluster.main.id + subnet_ids = data.aws_subnets.public.ids + security_group_id = data.aws_security_group.web.id + container_image = "your-ecr-repo:green" # TODO: 실제 이미지로 변경 + container_port = 8080 + desired_count = 1 +} + +output "green_service_name" { + value = module.ecs_service_green.service_name +} diff --git a/infra/main.tf b/infra/main.tf index e69de29..f1efcc7 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -0,0 +1,101 @@ +# main.tf - 메인 Terraform 설정 + +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +# AWS Provider 설정 +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "Terraform" + } + } +} + +# VPC 모듈 +module "vpc" { + source = "./modules/vpc" + + project_name = var.project_name + environment = var.environment + vpc_cidr = var.vpc_cidr +} + +# RDS 모듈 +module "rds" { + source = "./modules/rds" + + project_name = var.project_name + environment = var.environment + vpc_id = module.vpc.vpc_id + private_subnet_ids = module.vpc.private_subnet_ids + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password + db_instance_class = var.db_instance_class + db_security_group_id = module.vpc.db_security_group_id +} + +# EC2 인스턴스 모듈 +module "ec2" { + source = "./modules/ec2_instance" + + project_name = var.project_name + environment = var.environment + instance_type = var.instance_type + key_name = var.key_name + subnet_id = module.vpc.public_subnet_ids[0] + security_group_id = module.vpc.web_security_group_id +} + +# ECS 클러스터 모듈 +module "ecs_cluster" { + source = "./modules/ecs_cluster" + + project_name = var.project_name + environment = var.environment +} + +# ECS 서비스 모듈 +module "ecs_service" { + source = "./modules/ecs_service" + + project_name = var.project_name + environment = var.environment + cluster_id = module.ecs_cluster.cluster_id + subnet_ids = module.vpc.public_subnet_ids + security_group_id = module.vpc.web_security_group_id +} + +# Outputs +output "vpc_id" { + description = "VPC ID" + value = module.vpc.vpc_id +} + +output "ec2_public_ip" { + description = "EC2 퍼블릭 IP" + value = module.ec2.public_ip +} + +output "rds_endpoint" { + description = "RDS 엔드포인트" + value = module.rds.endpoint +} + +output "ecs_cluster_name" { + description = "ECS 클러스터 이름" + value = module.ecs_cluster.cluster_name +} diff --git a/infra/modules/ec2_instance/main.tf b/infra/modules/ec2_instance/main.tf index e69de29..ff90800 100644 --- a/infra/modules/ec2_instance/main.tf +++ b/infra/modules/ec2_instance/main.tf @@ -0,0 +1,69 @@ +# modules/ec2_instance/main.tf - EC2 인스턴스 설정 + +# Amazon Linux 2023 AMI +data "aws_ami" "amazon_linux" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# EC2 인스턴스 +resource "aws_instance" "main" { + ami = data.aws_ami.amazon_linux.id + instance_type = var.instance_type + key_name = var.key_name + subnet_id = var.subnet_id + vpc_security_group_ids = [var.security_group_id] + + associate_public_ip_address = true + + root_block_device { + volume_type = "gp3" + volume_size = 8 + delete_on_termination = true + encrypted = true + } + + user_data = <<-EOF + #!/bin/bash + yum update -y + yum install -y docker + systemctl start docker + systemctl enable docker + usermod -a -G docker ec2-user + + # Docker Compose 설치 + curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose + + # Java 17 설치 + yum install -y java-17-amazon-corretto-headless + EOF + + tags = { + Name = "${var.project_name}-${var.environment}-instance" + } + + lifecycle { + create_before_destroy = true + } +} + +# Elastic IP (선택사항) +resource "aws_eip" "main" { + instance = aws_instance.main.id + domain = "vpc" + + tags = { + Name = "${var.project_name}-${var.environment}-eip" + } +} diff --git a/infra/modules/ec2_instance/outputs.tf b/infra/modules/ec2_instance/outputs.tf index e69de29..da74a93 100644 --- a/infra/modules/ec2_instance/outputs.tf +++ b/infra/modules/ec2_instance/outputs.tf @@ -0,0 +1,21 @@ +# modules/ec2_instance/outputs.tf + +output "instance_id" { + description = "EC2 인스턴스 ID" + value = aws_instance.main.id +} + +output "public_ip" { + description = "퍼블릭 IP" + value = aws_eip.main.public_ip +} + +output "private_ip" { + description = "프라이빗 IP" + value = aws_instance.main.private_ip +} + +output "public_dns" { + description = "퍼블릭 DNS" + value = aws_instance.main.public_dns +} diff --git a/infra/modules/ec2_instance/variables.tf b/infra/modules/ec2_instance/variables.tf index e69de29..0fdba6d 100644 --- a/infra/modules/ec2_instance/variables.tf +++ b/infra/modules/ec2_instance/variables.tf @@ -0,0 +1,32 @@ +# modules/ec2_instance/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "instance_type" { + description = "EC2 인스턴스 타입" + type = string + default = "t2.nano" +} + +variable "key_name" { + description = "SSH 키 페어 이름" + type = string +} + +variable "subnet_id" { + description = "서브넷 ID" + type = string +} + +variable "security_group_id" { + description = "보안 그룹 ID" + type = string +} diff --git a/infra/modules/ecs_cluster/main.tf b/infra/modules/ecs_cluster/main.tf index e69de29..9f001bd 100644 --- a/infra/modules/ecs_cluster/main.tf +++ b/infra/modules/ecs_cluster/main.tf @@ -0,0 +1,28 @@ +# modules/ecs_cluster/main.tf - ECS 클러스터 설정 + +# ECS 클러스터 +resource "aws_ecs_cluster" "main" { + name = "${var.project_name}-${var.environment}-cluster" + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = { + Name = "${var.project_name}-${var.environment}-cluster" + } +} + +# ECS 클러스터 용량 공급자 +resource "aws_ecs_cluster_capacity_providers" "main" { + cluster_name = aws_ecs_cluster.main.name + + capacity_providers = ["FARGATE", "FARGATE_SPOT"] + + default_capacity_provider_strategy { + base = 1 + weight = 100 + capacity_provider = "FARGATE" + } +} diff --git a/infra/modules/ecs_cluster/outputs.tf b/infra/modules/ecs_cluster/outputs.tf index e69de29..6095e71 100644 --- a/infra/modules/ecs_cluster/outputs.tf +++ b/infra/modules/ecs_cluster/outputs.tf @@ -0,0 +1,16 @@ +# modules/ecs_cluster/outputs.tf + +output "cluster_id" { + description = "ECS 클러스터 ID" + value = aws_ecs_cluster.main.id +} + +output "cluster_arn" { + description = "ECS 클러스터 ARN" + value = aws_ecs_cluster.main.arn +} + +output "cluster_name" { + description = "ECS 클러스터 이름" + value = aws_ecs_cluster.main.name +} diff --git a/infra/modules/ecs_cluster/variables.tf b/infra/modules/ecs_cluster/variables.tf index e69de29..1b64da7 100644 --- a/infra/modules/ecs_cluster/variables.tf +++ b/infra/modules/ecs_cluster/variables.tf @@ -0,0 +1,11 @@ +# modules/ecs_cluster/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} diff --git a/infra/modules/ecs_service/main.tf b/infra/modules/ecs_service/main.tf index e69de29..9372d9f 100644 --- a/infra/modules/ecs_service/main.tf +++ b/infra/modules/ecs_service/main.tf @@ -0,0 +1,137 @@ +# modules/ecs_service/main.tf - ECS 서비스 설정 + +# CloudWatch 로그 그룹 +resource "aws_cloudwatch_log_group" "main" { + name = "/ecs/${var.project_name}-${var.environment}" + retention_in_days = 7 + + tags = { + Name = "${var.project_name}-${var.environment}-logs" + } +} + +# ECS Task Execution Role +resource "aws_iam_role" "ecs_task_execution" { + name = "${var.project_name}-${var.environment}-ecs-task-execution" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${var.project_name}-${var.environment}-ecs-task-execution" + } +} + +resource "aws_iam_role_policy_attachment" "ecs_task_execution" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +# ECS Task Role +resource "aws_iam_role" "ecs_task" { + name = "${var.project_name}-${var.environment}-ecs-task" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${var.project_name}-${var.environment}-ecs-task" + } +} + +# ECS Task Definition +resource "aws_ecs_task_definition" "main" { + family = "${var.project_name}-${var.environment}" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.task_cpu + memory = var.task_memory + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([ + { + name = "${var.project_name}-${var.environment}" + image = var.container_image + + portMappings = [ + { + containerPort = var.container_port + hostPort = var.container_port + protocol = "tcp" + } + ] + + environment = [ + { + name = "SPRING_PROFILES_ACTIVE" + value = var.environment + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.main.name + "awslogs-region" = data.aws_region.current.name + "awslogs-stream-prefix" = "ecs" + } + } + + essential = true + } + ]) + + tags = { + Name = "${var.project_name}-${var.environment}-task" + } +} + +# 현재 리전 +data "aws_region" "current" {} + +# ECS Service +resource "aws_ecs_service" "main" { + name = "${var.project_name}-${var.environment}-service" + cluster = var.cluster_id + task_definition = aws_ecs_task_definition.main.arn + desired_count = var.desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = var.subnet_ids + security_groups = [var.security_group_id] + assign_public_ip = true + } + + deployment_maximum_percent = 200 + deployment_minimum_healthy_percent = 100 + + lifecycle { + ignore_changes = [desired_count] + } + + tags = { + Name = "${var.project_name}-${var.environment}-service" + } +} \ No newline at end of file diff --git a/infra/modules/ecs_service/outputs.tf b/infra/modules/ecs_service/outputs.tf index e69de29..92a932f 100644 --- a/infra/modules/ecs_service/outputs.tf +++ b/infra/modules/ecs_service/outputs.tf @@ -0,0 +1,21 @@ +# modules/ecs_service/outputs.tf + +output "service_id" { + description = "ECS 서비스 ID" + value = aws_ecs_service.main.id +} + +output "service_name" { + description = "ECS 서비스 이름" + value = aws_ecs_service.main.name +} + +output "task_definition_arn" { + description = "Task Definition ARN" + value = aws_ecs_task_definition.main.arn +} + +output "log_group_name" { + description = "CloudWatch 로그 그룹 이름" + value = aws_cloudwatch_log_group.main.name +} diff --git a/infra/modules/ecs_service/variables.tf b/infra/modules/ecs_service/variables.tf index e69de29..b628e8e 100644 --- a/infra/modules/ecs_service/variables.tf +++ b/infra/modules/ecs_service/variables.tf @@ -0,0 +1,56 @@ +# modules/ecs_service/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "cluster_id" { + description = "ECS 클러스터 ID" + type = string +} + +variable "subnet_ids" { + description = "서브넷 ID 목록" + type = list(string) +} + +variable "security_group_id" { + description = "보안 그룹 ID" + type = string +} + +variable "container_image" { + description = "컨테이너 이미지" + type = string + default = "nginx:latest" # TODO: 실제 이미지로 변경 +} + +variable "container_port" { + description = "컨테이너 포트" + type = number + default = 8080 +} + +variable "task_cpu" { + description = "Task CPU" + type = string + default = "256" +} + +variable "task_memory" { + description = "Task 메모리" + type = string + default = "512" +} + +variable "desired_count" { + description = "원하는 태스크 수" + type = number + default = 1 +} diff --git a/infra/modules/rds/main.tf b/infra/modules/rds/main.tf index e69de29..51ef03c 100644 --- a/infra/modules/rds/main.tf +++ b/infra/modules/rds/main.tf @@ -0,0 +1,76 @@ +# modules/rds/main.tf - PostgreSQL RDS 설정 + +# DB 서브넷 그룹 +resource "aws_db_subnet_group" "main" { + name = "${var.project_name}-${var.environment}-db-subnet-group" + description = "Database subnet group" + subnet_ids = var.private_subnet_ids + + tags = { + Name = "${var.project_name}-${var.environment}-db-subnet-group" + } +} + +# RDS PostgreSQL 인스턴스 +resource "aws_db_instance" "main" { + identifier = "${var.project_name}-${var.environment}-db" + + # 엔진 설정 + engine = "postgres" + engine_version = "15.4" + instance_class = var.db_instance_class + allocated_storage = 20 + max_allocated_storage = 100 + storage_type = "gp2" + storage_encrypted = true + + # 데이터베이스 설정 + db_name = var.db_name + username = var.db_username + password = var.db_password + port = 5432 + + # 네트워크 설정 + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [var.db_security_group_id] + publicly_accessible = false + multi_az = false # 비용 절감을 위해 단일 AZ + + # 백업 설정 + backup_retention_period = 7 + backup_window = "03:00-04:00" + maintenance_window = "Mon:04:00-Mon:05:00" + + # 기타 설정 + skip_final_snapshot = true # 개발환경용 + final_snapshot_identifier = "${var.project_name}-${var.environment}-final-snapshot" + deletion_protection = false # 개발환경용 + auto_minor_version_upgrade = true + + # 파라미터 그룹 + parameter_group_name = aws_db_parameter_group.main.name + + tags = { + Name = "${var.project_name}-${var.environment}-db" + } +} + +# DB 파라미터 그룹 +resource "aws_db_parameter_group" "main" { + name = "${var.project_name}-${var.environment}-pg15" + family = "postgres15" + + parameter { + name = "log_connections" + value = "1" + } + + parameter { + name = "log_disconnections" + value = "1" + } + + tags = { + Name = "${var.project_name}-${var.environment}-pg15" + } +} diff --git a/infra/modules/rds/outputs.tf b/infra/modules/rds/outputs.tf index e69de29..af9787b 100644 --- a/infra/modules/rds/outputs.tf +++ b/infra/modules/rds/outputs.tf @@ -0,0 +1,26 @@ +# modules/rds/outputs.tf + +output "endpoint" { + description = "RDS 엔드포인트" + value = aws_db_instance.main.endpoint +} + +output "address" { + description = "RDS 주소 (포트 제외)" + value = aws_db_instance.main.address +} + +output "port" { + description = "RDS 포트" + value = aws_db_instance.main.port +} + +output "db_name" { + description = "데이터베이스 이름" + value = aws_db_instance.main.db_name +} + +output "db_instance_id" { + description = "RDS 인스턴스 ID" + value = aws_db_instance.main.id +} diff --git a/infra/modules/rds/variables.tf b/infra/modules/rds/variables.tf index e69de29..1692ed0 100644 --- a/infra/modules/rds/variables.tf +++ b/infra/modules/rds/variables.tf @@ -0,0 +1,48 @@ +# modules/rds/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "private_subnet_ids" { + description = "프라이빗 서브넷 ID 목록" + type = list(string) +} + +variable "db_security_group_id" { + description = "DB 보안 그룹 ID" + type = string +} + +variable "db_name" { + description = "데이터베이스 이름" + type = string +} + +variable "db_username" { + description = "데이터베이스 사용자명" + type = string +} + +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} + +variable "db_instance_class" { + description = "RDS 인스턴스 클래스" + type = string + default = "db.t3.micro" +} diff --git a/infra/modules/vpc/main.tf b/infra/modules/vpc/main.tf index e69de29..ba46912 100644 --- a/infra/modules/vpc/main.tf +++ b/infra/modules/vpc/main.tf @@ -0,0 +1,148 @@ +# modules/vpc/main.tf - VPC 및 네트워크 설정 + +# 가용 영역 데이터 +data "aws_availability_zones" "available" { + state = "available" +} + +# VPC +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${var.project_name}-${var.environment}-vpc" + } +} + +# 인터넷 게이트웨이 +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.project_name}-${var.environment}-igw" + } +} + +# 퍼블릭 서브넷 (2개 AZ) +resource "aws_subnet" "public" { + count = 2 + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) + availability_zone = data.aws_availability_zones.available.names[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "${var.project_name}-${var.environment}-public-${count.index + 1}" + Type = "Public" + } +} + +# 프라이빗 서브넷 (RDS용, 2개 AZ) +resource "aws_subnet" "private" { + count = 2 + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10) + availability_zone = data.aws_availability_zones.available.names[count.index] + + tags = { + Name = "${var.project_name}-${var.environment}-private-${count.index + 1}" + Type = "Private" + } +} + +# 퍼블릭 라우트 테이블 +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "${var.project_name}-${var.environment}-public-rt" + } +} + +# 퍼블릭 서브넷 라우트 테이블 연결 +resource "aws_route_table_association" "public" { + count = 2 + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +# 웹 서버용 보안 그룹 (HTTP, HTTPS, SSH) +resource "aws_security_group" "web" { + name = "${var.project_name}-${var.environment}-web-sg" + description = "Security group for web servers" + vpc_id = aws_vpc.main.id + + # SSH (22) + ingress { + description = "SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTP (80) + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTPS (443) + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # 모든 아웃바운드 허용 + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-${var.environment}-web-sg" + } +} + +# RDS용 보안 그룹 (PostgreSQL) +resource "aws_security_group" "db" { + name = "${var.project_name}-${var.environment}-db-sg" + description = "Security group for RDS PostgreSQL" + vpc_id = aws_vpc.main.id + + # PostgreSQL (5432) - 웹 서버에서만 접근 + ingress { + description = "PostgreSQL from web servers" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.web.id] + } + + # 모든 아웃바운드 허용 + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-${var.environment}-db-sg" + } +} diff --git a/infra/modules/vpc/outputs.tf b/infra/modules/vpc/outputs.tf index e69de29..b996b52 100644 --- a/infra/modules/vpc/outputs.tf +++ b/infra/modules/vpc/outputs.tf @@ -0,0 +1,31 @@ +# modules/vpc/outputs.tf + +output "vpc_id" { + description = "VPC ID" + value = aws_vpc.main.id +} + +output "public_subnet_ids" { + description = "퍼블릭 서브넷 ID 목록" + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + description = "프라이빗 서브넷 ID 목록" + value = aws_subnet.private[*].id +} + +output "web_security_group_id" { + description = "웹 서버 보안 그룹 ID" + value = aws_security_group.web.id +} + +output "db_security_group_id" { + description = "DB 보안 그룹 ID" + value = aws_security_group.db.id +} + +output "internet_gateway_id" { + description = "인터넷 게이트웨이 ID" + value = aws_internet_gateway.main.id +} diff --git a/infra/modules/vpc/variables.tf b/infra/modules/vpc/variables.tf index e69de29..3b0e024 100644 --- a/infra/modules/vpc/variables.tf +++ b/infra/modules/vpc/variables.tf @@ -0,0 +1,17 @@ +# modules/vpc/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "vpc_cidr" { + description = "VPC CIDR 블록" + type = string + default = "10.0.0.0/16" +} diff --git a/infra/terraform.tfvars b/infra/terraform.tfvars index e69de29..01886d3 100644 --- a/infra/terraform.tfvars +++ b/infra/terraform.tfvars @@ -0,0 +1,18 @@ +# terraform.tfvars - 변수 값 설정 + +aws_region = "ap-northeast-2" +project_name = "spring-vote" +environment = "dev" + +# VPC +vpc_cidr = "10.0.0.0/16" + +# EC2 +instance_type = "t2.nano" +key_name = "your-key-name" # TODO: 실제 키 페어 이름으로 변경 + +# RDS +db_name = "springvote" +db_username = "admin" +db_password = "YourSecurePassword123!" # TODO: 실제 비밀번호로 변경 +db_instance_class = "db.t3.micro" diff --git a/infra/variables.tf b/infra/variables.tf index e69de29..f27fbe2 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -0,0 +1,63 @@ +# variables.tf - 전역 변수 정의 + +variable "aws_region" { + description = "AWS 리전" + type = string + default = "ap-northeast-2" +} + +variable "project_name" { + description = "프로젝트 이름" + type = string + default = "spring-vote" +} + +variable "environment" { + description = "환경 (dev, staging, prod)" + type = string + default = "dev" +} + +# VPC 관련 +variable "vpc_cidr" { + description = "VPC CIDR 블록" + type = string + default = "10.0.0.0/16" +} + +# EC2 관련 +variable "instance_type" { + description = "EC2 인스턴스 타입" + type = string + default = "t2.nano" +} + +variable "key_name" { + description = "SSH 키 페어 이름" + type = string +} + +# RDS 관련 +variable "db_name" { + description = "데이터베이스 이름" + type = string + default = "springvote" +} + +variable "db_username" { + description = "데이터베이스 사용자명" + type = string + default = "admin" +} + +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} + +variable "db_instance_class" { + description = "RDS 인스턴스 클래스" + type = string + default = "db.t3.micro" +} From 1462f021ceee5371c3b0130f4f915170591a378a Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Tue, 16 Dec 2025 20:43:12 +0900 Subject: [PATCH 05/52] chore: Remove tfvars from tracking --- infra/terraform.tfvars | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 infra/terraform.tfvars diff --git a/infra/terraform.tfvars b/infra/terraform.tfvars deleted file mode 100644 index 01886d3..0000000 --- a/infra/terraform.tfvars +++ /dev/null @@ -1,18 +0,0 @@ -# terraform.tfvars - 변수 값 설정 - -aws_region = "ap-northeast-2" -project_name = "spring-vote" -environment = "dev" - -# VPC -vpc_cidr = "10.0.0.0/16" - -# EC2 -instance_type = "t2.nano" -key_name = "your-key-name" # TODO: 실제 키 페어 이름으로 변경 - -# RDS -db_name = "springvote" -db_username = "admin" -db_password = "YourSecurePassword123!" # TODO: 실제 비밀번호로 변경 -db_instance_class = "db.t3.micro" From 75b52921827b4e33a150a18e2a8722a18ac5a5cf Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Tue, 16 Dec 2025 20:44:37 +0900 Subject: [PATCH 06/52] =?UTF-8?q?docs:=20tfvars=20=EC=98=88=EC=8B=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform.tfvars.example | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 infra/terraform.tfvars.example diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example new file mode 100644 index 0000000..01886d3 --- /dev/null +++ b/infra/terraform.tfvars.example @@ -0,0 +1,18 @@ +# terraform.tfvars - 변수 값 설정 + +aws_region = "ap-northeast-2" +project_name = "spring-vote" +environment = "dev" + +# VPC +vpc_cidr = "10.0.0.0/16" + +# EC2 +instance_type = "t2.nano" +key_name = "your-key-name" # TODO: 실제 키 페어 이름으로 변경 + +# RDS +db_name = "springvote" +db_username = "admin" +db_password = "YourSecurePassword123!" # TODO: 실제 비밀번호로 변경 +db_instance_class = "db.t3.micro" From e24dbf39253cb21136d55eaeac7b22306b4c1cff Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Tue, 16 Dec 2025 23:23:34 +0900 Subject: [PATCH 07/52] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/deploy.yml | 56 ++++++++++++++++++++++++++++++ Dockerfile | 10 ++++++ infra/main.tf | 20 +++++++++++ infra/modules/ec2_instance/main.tf | 2 +- infra/modules/rds/main.tf | 2 +- 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 .github/deploy.yml create mode 100644 Dockerfile diff --git a/.github/deploy.yml b/.github/deploy.yml new file mode 100644 index 0000000..a5a024b --- /dev/null +++ b/.github/deploy.yml @@ -0,0 +1,56 @@ +name: Deploy to AWS + +on: + push: + branches: [main] + +env: + AWS_REGION: ap-northeast-2 + ECR_REPOSITORY: spring-vote-dev + ECS_CLUSTER: spring-vote-dev-cluster + ECS_SERVICE: spring-vote-dev-service + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Build with Gradle + run: chmod +x ./gradlew && ./gradlew build -x test + + - name: Build and push Docker image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest + + - name: Deploy to ECS + run: | + aws ecs update-service \ + --cluster $ECS_CLUSTER \ + --service $ECS_SERVICE \ + --force-new-deployment \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9988f54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM eclipse-temurin:21-jdk-alpine AS build +WORKDIR /app +COPY . . +RUN chmod +x ./gradlew && ./gradlew build -x test + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=build /app/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/infra/main.tf b/infra/main.tf index f1efcc7..f68a553 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -99,3 +99,23 @@ output "ecs_cluster_name" { description = "ECS 클러스터 이름" value = module.ecs_cluster.cluster_name } + +# ECR 리포지토리 +resource "aws_ecr_repository" "main" { + name = "${var.project_name}-${var.environment}" + image_tag_mutability = "MUTABLE" + force_delete = true + + image_scanning_configuration { + scan_on_push = true + } + + tags = { + Name = "${var.project_name}-${var.environment}-ecr" + } +} + +output "ecr_repository_url" { + description = "ECR 리포지토리 URL" + value = aws_ecr_repository.main.repository_url +} \ No newline at end of file diff --git a/infra/modules/ec2_instance/main.tf b/infra/modules/ec2_instance/main.tf index ff90800..fd3707e 100644 --- a/infra/modules/ec2_instance/main.tf +++ b/infra/modules/ec2_instance/main.tf @@ -28,7 +28,7 @@ resource "aws_instance" "main" { root_block_device { volume_type = "gp3" - volume_size = 8 + volume_size = 30 delete_on_termination = true encrypted = true } diff --git a/infra/modules/rds/main.tf b/infra/modules/rds/main.tf index 51ef03c..60a8c02 100644 --- a/infra/modules/rds/main.tf +++ b/infra/modules/rds/main.tf @@ -17,7 +17,7 @@ resource "aws_db_instance" "main" { # 엔진 설정 engine = "postgres" - engine_version = "15.4" + engine_version = "15.12" instance_class = var.db_instance_class allocated_storage = 20 max_allocated_storage = 100 From 37af03aae234d34d5b92d0f06078e5e6afbdbfb7 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Tue, 16 Dec 2025 23:48:28 +0900 Subject: [PATCH 08/52] =?UTF-8?q?feat:=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84,=20gitignore=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 ++++ build.gradle | 1 + settings.gradle | 2 +- .../vote/domain/team/domain/Team.java | 28 ++++++++++++++++ .../vote/domain/user/domain/User.java | 33 +++++++++++++++++++ .../vote/domain/vote/domain/PartVote.java | 25 ++++++++++++++ .../vote/domain/vote/domain/TeamVote.java | 27 +++++++++++++++ src/main/resources/application.yml | 20 +++++++++++ 8 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/diggindie/vote/domain/team/domain/Team.java create mode 100644 src/main/java/com/diggindie/vote/domain/user/domain/User.java create mode 100644 src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java create mode 100644 src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java diff --git a/.gitignore b/.gitignore index c2065bc..68c663a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,10 @@ out/ ### VS Code ### .vscode/ + +# Terraform +.terraform/ +*.tfstate +*.tfstate.* +.terraform.lock.hcl +*.tfvars \ No newline at end of file diff --git a/build.gradle b/build.gradle index 41e1669..f614035 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/settings.gradle b/settings.gradle index bfce6e0..550be7a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'vote' +rootProject.name = 'spring-vote-22nd' \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java new file mode 100644 index 0000000..bf2ccc3 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java @@ -0,0 +1,28 @@ +package com.diggindie.vote.domain.team.domain; + +import com.diggindie.vote.domain.user.domain.User; +import jakarta.persistence.*; +import lombok.*; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "team") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Team { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_id") + private Long id; + + @Column(name = "name", nullable = false, length = 20) + private String name; + + @Column(name = "proposal", length = 200) + private String proposal; + + @OneToMany(mappedBy = "team") + private List users = new ArrayList<>(); +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/user/domain/User.java b/src/main/java/com/diggindie/vote/domain/user/domain/User.java new file mode 100644 index 0000000..10c2a1c --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/user/domain/User.java @@ -0,0 +1,33 @@ +package com.diggindie.vote.domain.user.domain; + +import com.diggindie.vote.domain.team.domain.Team; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "user") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + private Team team; + + @Column(name = "login_id", nullable = false, unique = true, length = 20) + private String loginId; + + @Column(name = "email", nullable = false, unique = true, length = 50) + private String email; + + @Column(name = "password", nullable = false, length = 100) + private String password; + + @Column(name = "name", nullable = false, length = 10) + private String name; +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java new file mode 100644 index 0000000..050be3f --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java @@ -0,0 +1,25 @@ +package com.diggindie.vote.domain.vote.domain; + +import com.diggindie.vote.domain.user.domain.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "part_vote") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PartVote { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "part_vote_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voter_user_id", nullable = false, unique = true) + private User voter; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "candidate_user_id", nullable = false) + private User candidate; +} diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java new file mode 100644 index 0000000..e406195 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java @@ -0,0 +1,27 @@ +package com.diggindie.vote.domain.vote.domain; + +import com.diggindie.vote.domain.team.domain.Team; +import com.diggindie.vote.domain.user.domain.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "team_vote") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamVote { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_vote_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voter_user_id", nullable = false, unique = true) + private User voter; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + private Team team; +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3761c62..7b2eec9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,23 @@ spring: application: name: vote + + datasource: + url: jdbc:mysql://localhost:3306/vote?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: 비밀번호입력 + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type.descriptor.sql: trace \ No newline at end of file From c3e1deb176d3feaed77683274271c993204a23b3 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Tue, 16 Dec 2025 23:54:20 +0900 Subject: [PATCH 09/52] =?UTF-8?q?feat:=20yml=20db=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7b2eec9..4bd15c5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,9 +3,9 @@ spring: name: vote datasource: - url: jdbc:mysql://localhost:3306/vote?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + url: jdbc:mysql://localhost:3306/vote?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true username: root - password: 비밀번호입력 + password: 로컬비번 !! driver-class-name: com.mysql.cj.jdbc.Driver jpa: From 435aec69759a19561047d5f329724d0cd1289ea7 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 00:14:51 +0900 Subject: [PATCH 10/52] =?UTF-8?q?feat:=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20candidate=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/diggindie/vote/common/enums/Part.java | 6 ++++++ .../vote/domain/member/domain/Candidate.java | 21 +++++++++++++++++++ .../User.java => member/domain/Member.java} | 17 +++++++++++---- .../vote/domain/team/domain/Team.java | 4 ++-- .../vote/domain/vote/domain/PartVote.java | 11 +++++----- .../vote/domain/vote/domain/TeamVote.java | 6 +++--- 6 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/diggindie/vote/common/enums/Part.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java rename src/main/java/com/diggindie/vote/domain/{user/domain/User.java => member/domain/Member.java} (64%) diff --git a/src/main/java/com/diggindie/vote/common/enums/Part.java b/src/main/java/com/diggindie/vote/common/enums/Part.java new file mode 100644 index 0000000..56f7ac0 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/enums/Part.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.common.enums; + +public enum Part { + BACKEND, + FRONTEND +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java b/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java new file mode 100644 index 0000000..10b8065 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java @@ -0,0 +1,21 @@ +package com.diggindie.vote.domain.member.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "candidate") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Candidate { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "candidate_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false, unique = true) + private Member member; + +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/user/domain/User.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java similarity index 64% rename from src/main/java/com/diggindie/vote/domain/user/domain/User.java rename to src/main/java/com/diggindie/vote/domain/member/domain/Member.java index 10c2a1c..c75d3ca 100644 --- a/src/main/java/com/diggindie/vote/domain/user/domain/User.java +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -1,24 +1,29 @@ -package com.diggindie.vote.domain.user.domain; +package com.diggindie.vote.domain.member.domain; +import com.diggindie.vote.common.enums.Part; import com.diggindie.vote.domain.team.domain.Team; import jakarta.persistence.*; import lombok.*; @Entity -@Table(name = "user") +@Table(name = "member") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class User { +public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_id") + @Column(name = "member_id") private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id", nullable = false) private Team team; + @Enumerated(EnumType.STRING) + @Column(name = "part", nullable = false, length = 20) + private Part part; + @Column(name = "login_id", nullable = false, unique = true, length = 20) private String loginId; @@ -30,4 +35,8 @@ public class User { @Column(name = "name", nullable = false, length = 10) private String name; + + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private Candidate candidate; + } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java index bf2ccc3..cbf2507 100644 --- a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java +++ b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java @@ -1,6 +1,6 @@ package com.diggindie.vote.domain.team.domain; -import com.diggindie.vote.domain.user.domain.User; +import com.diggindie.vote.domain.member.domain.Member; import jakarta.persistence.*; import lombok.*; import java.util.ArrayList; @@ -24,5 +24,5 @@ public class Team { private String proposal; @OneToMany(mappedBy = "team") - private List users = new ArrayList<>(); + private List members = new ArrayList<>(); } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java index 050be3f..c531244 100644 --- a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java +++ b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java @@ -1,6 +1,7 @@ package com.diggindie.vote.domain.vote.domain; -import com.diggindie.vote.domain.user.domain.User; +import com.diggindie.vote.domain.member.domain.Candidate; +import com.diggindie.vote.domain.member.domain.Member; import jakarta.persistence.*; import lombok.*; @@ -16,10 +17,10 @@ public class PartVote { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "voter_user_id", nullable = false, unique = true) - private User voter; + @JoinColumn(name = "voter_member_id", nullable = false, unique = true) + private Member voter; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "candidate_user_id", nullable = false) - private User candidate; + @JoinColumn(name = "candidate_id", nullable = false) + private Candidate candidate; } diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java index e406195..099c7d8 100644 --- a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java +++ b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java @@ -1,7 +1,7 @@ package com.diggindie.vote.domain.vote.domain; import com.diggindie.vote.domain.team.domain.Team; -import com.diggindie.vote.domain.user.domain.User; +import com.diggindie.vote.domain.member.domain.Member; import jakarta.persistence.*; import lombok.*; @@ -17,8 +17,8 @@ public class TeamVote { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "voter_user_id", nullable = false, unique = true) - private User voter; + @JoinColumn(name = "voter_member_id", nullable = false, unique = true) + private Member voter; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id", nullable = false) From f0d314f516f5806070fca785d40c910efeaac610 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 00:21:07 +0900 Subject: [PATCH 11/52] =?UTF-8?q?feat:=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EB=B0=8F=20name=20=EC=98=88=EC=95=BD=EC=96=B4=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/diggindie/vote/domain/member/domain/Candidate.java | 4 ++++ .../java/com/diggindie/vote/domain/member/domain/Member.java | 4 ++-- src/main/java/com/diggindie/vote/domain/team/domain/Team.java | 4 ++-- .../java/com/diggindie/vote/domain/vote/domain/PartVote.java | 4 +++- .../java/com/diggindie/vote/domain/vote/domain/TeamVote.java | 4 +++- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java b/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java index 10b8065..7f855f0 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java @@ -18,4 +18,8 @@ public class Candidate { @JoinColumn(name = "member_id", nullable = false, unique = true) private Member member; + public Candidate(Member member) { + this.member = member; + } + } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java index c75d3ca..76634df 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -33,8 +33,8 @@ public class Member { @Column(name = "password", nullable = false, length = 100) private String password; - @Column(name = "name", nullable = false, length = 10) - private String name; + @Column(name = "membername", nullable = false, length = 10) + private String memberName; @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private Candidate candidate; diff --git a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java index cbf2507..5c4afba 100644 --- a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java +++ b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java @@ -17,8 +17,8 @@ public class Team { @Column(name = "team_id") private Long id; - @Column(name = "name", nullable = false, length = 20) - private String name; + @Column(name = "teamname", nullable = false, length = 20) + private String teamName; @Column(name = "proposal", length = 200) private String proposal; diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java index c531244..e71574c 100644 --- a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java +++ b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java @@ -6,7 +6,9 @@ import lombok.*; @Entity -@Table(name = "part_vote") +@Table(name = "part_vote", indexes = { + @Index(name = "idx_part_vote_candidate", columnList = "candidate_id") +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PartVote { diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java index 099c7d8..6d94a1d 100644 --- a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java +++ b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java @@ -6,7 +6,9 @@ import lombok.*; @Entity -@Table(name = "team_vote") +@Table(name = "team_vote", indexes = { + @Index(name = "idx_team_vote_team", columnList = "team_id") +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TeamVote { From fcbe2b9731d849f7a37acff87d5d21181c1d864b Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 00:29:48 +0900 Subject: [PATCH 12/52] =?UTF-8?q?ci:=20deploy.yml=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=88=98=EB=8F=99=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/deploy.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/deploy.yml b/.github/deploy.yml index a5a024b..402d53d 100644 --- a/.github/deploy.yml +++ b/.github/deploy.yml @@ -2,7 +2,8 @@ name: Deploy to AWS on: push: - branches: [main] + branches: [master] + workflow_dispatch: env: AWS_REGION: ap-northeast-2 @@ -13,7 +14,7 @@ env: jobs: deploy: runs-on: ubuntu-latest - + steps: - name: Checkout uses: actions/checkout@v4 @@ -29,10 +30,10 @@ jobs: id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Build with Gradle From 769a7ce0da67fb4753429a445b0b76a4fc707ab9 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 00:40:34 +0900 Subject: [PATCH 13/52] =?UTF-8?q?[DEPLOY]=20dev=20=E2=86=92=20master=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init: terraform initial settings * init: terraform initial settings * chore: Remove tfvars from tracking * docs: tfvars 예시 파일 * feat: 배포 관련 코드 작성 * feat: 엔티티 구현, gitignore 수정 * feat: yml db 연동되도록 수정 * feat: 엔티티 수정 및 candidate 추가 * feat: 인덱스 및 name 예약어 관련 이름 변경 * ci: deploy.yml 버전 수정 및 수동 배포 추가 --- .github/deploy.yml | 57 +++++++ .gitignore | 21 +++ Dockerfile | 10 ++ build.gradle | 1 + infra/blue/main.tf | 74 +++++++++ infra/green/main.tf | 74 +++++++++ infra/main.tf | 121 ++++++++++++++ infra/modules/ec2_instance/main.tf | 69 ++++++++ infra/modules/ec2_instance/outputs.tf | 21 +++ infra/modules/ec2_instance/variables.tf | 32 ++++ infra/modules/ecs_cluster/main.tf | 28 ++++ infra/modules/ecs_cluster/outputs.tf | 16 ++ infra/modules/ecs_cluster/variables.tf | 11 ++ infra/modules/ecs_service/main.tf | 137 ++++++++++++++++ infra/modules/ecs_service/outputs.tf | 21 +++ infra/modules/ecs_service/variables.tf | 56 +++++++ infra/modules/rds/main.tf | 76 +++++++++ infra/modules/rds/outputs.tf | 26 +++ infra/modules/rds/variables.tf | 48 ++++++ infra/modules/vpc/main.tf | 148 ++++++++++++++++++ infra/modules/vpc/outputs.tf | 31 ++++ infra/modules/vpc/variables.tf | 17 ++ infra/terraform.tfvars.example | 18 +++ infra/variables.tf | 63 ++++++++ settings.gradle | 2 +- .../com/diggindie/vote/common/enums/Part.java | 6 + .../vote/domain/member/domain/Candidate.java | 25 +++ .../vote/domain/member/domain/Member.java | 42 +++++ .../vote/domain/team/domain/Team.java | 28 ++++ .../vote/domain/vote/domain/PartVote.java | 28 ++++ .../vote/domain/vote/domain/TeamVote.java | 29 ++++ src/main/resources/application.yml | 20 +++ 32 files changed, 1355 insertions(+), 1 deletion(-) create mode 100644 .github/deploy.yml create mode 100644 Dockerfile create mode 100644 infra/blue/main.tf create mode 100644 infra/green/main.tf create mode 100644 infra/main.tf create mode 100644 infra/modules/ec2_instance/main.tf create mode 100644 infra/modules/ec2_instance/outputs.tf create mode 100644 infra/modules/ec2_instance/variables.tf create mode 100644 infra/modules/ecs_cluster/main.tf create mode 100644 infra/modules/ecs_cluster/outputs.tf create mode 100644 infra/modules/ecs_cluster/variables.tf create mode 100644 infra/modules/ecs_service/main.tf create mode 100644 infra/modules/ecs_service/outputs.tf create mode 100644 infra/modules/ecs_service/variables.tf create mode 100644 infra/modules/rds/main.tf create mode 100644 infra/modules/rds/outputs.tf create mode 100644 infra/modules/rds/variables.tf create mode 100644 infra/modules/vpc/main.tf create mode 100644 infra/modules/vpc/outputs.tf create mode 100644 infra/modules/vpc/variables.tf create mode 100644 infra/terraform.tfvars.example create mode 100644 infra/variables.tf create mode 100644 src/main/java/com/diggindie/vote/common/enums/Part.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/domain/Member.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/domain/Team.java create mode 100644 src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java create mode 100644 src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java diff --git a/.github/deploy.yml b/.github/deploy.yml new file mode 100644 index 0000000..402d53d --- /dev/null +++ b/.github/deploy.yml @@ -0,0 +1,57 @@ +name: Deploy to AWS + +on: + push: + branches: [master] + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + ECR_REPOSITORY: spring-vote-dev + ECS_CLUSTER: spring-vote-dev-cluster + ECS_SERVICE: spring-vote-dev-service + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Build with Gradle + run: chmod +x ./gradlew && ./gradlew build -x test + + - name: Build and push Docker image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest + + - name: Deploy to ECS + run: | + aws ecs update-service \ + --cluster $ECS_CLUSTER \ + --service $ECS_SERVICE \ + --force-new-deployment \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2065bc..983a5f7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,24 @@ out/ ### VS Code ### .vscode/ + +# Terraform +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# 민감 정보 +*.tfvars +*.tfvars.json +*.auto.tfvars +*.auto.tfvars.json + +# 로컬 백엔드 +terraform.tfstate.d/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9988f54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM eclipse-temurin:21-jdk-alpine AS build +WORKDIR /app +COPY . . +RUN chmod +x ./gradlew && ./gradlew build -x test + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=build /app/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 41e1669..f614035 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/infra/blue/main.tf b/infra/blue/main.tf new file mode 100644 index 0000000..e24c484 --- /dev/null +++ b/infra/blue/main.tf @@ -0,0 +1,74 @@ +# blue/main.tf - Blue 환경 배포 설정 + +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" + + default_tags { + tags = { + Project = "spring-vote" + Environment = "blue" + ManagedBy = "Terraform" + } + } +} + +# 기존 인프라 참조 +data "aws_vpc" "main" { + tags = { + Name = "spring-vote-dev-vpc" + } +} + +data "aws_subnets" "public" { + filter { + name = "vpc-id" + values = [data.aws_vpc.main.id] + } + + filter { + name = "tag:Type" + values = ["Public"] + } +} + +data "aws_security_group" "web" { + vpc_id = data.aws_vpc.main.id + + filter { + name = "group-name" + values = ["spring-vote-dev-web-sg"] + } +} + +data "aws_ecs_cluster" "main" { + cluster_name = "spring-vote-dev-cluster" +} + +# Blue ECS Service +module "ecs_service_blue" { + source = "../modules/ecs_service" + + project_name = "spring-vote" + environment = "blue" + cluster_id = data.aws_ecs_cluster.main.id + subnet_ids = data.aws_subnets.public.ids + security_group_id = data.aws_security_group.web.id + container_image = "your-ecr-repo:blue" # TODO: 실제 이미지로 변경 + container_port = 8080 + desired_count = 1 +} + +output "blue_service_name" { + value = module.ecs_service_blue.service_name +} diff --git a/infra/green/main.tf b/infra/green/main.tf new file mode 100644 index 0000000..c10989d --- /dev/null +++ b/infra/green/main.tf @@ -0,0 +1,74 @@ +# green/main.tf - Green 환경 배포 설정 + +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" + + default_tags { + tags = { + Project = "spring-vote" + Environment = "green" + ManagedBy = "Terraform" + } + } +} + +# 기존 인프라 참조 (Remote State 또는 Data Source 사용) +data "aws_vpc" "main" { + tags = { + Name = "spring-vote-dev-vpc" + } +} + +data "aws_subnets" "public" { + filter { + name = "vpc-id" + values = [data.aws_vpc.main.id] + } + + filter { + name = "tag:Type" + values = ["Public"] + } +} + +data "aws_security_group" "web" { + vpc_id = data.aws_vpc.main.id + + filter { + name = "group-name" + values = ["spring-vote-dev-web-sg"] + } +} + +data "aws_ecs_cluster" "main" { + cluster_name = "spring-vote-dev-cluster" +} + +# Green ECS Service +module "ecs_service_green" { + source = "../modules/ecs_service" + + project_name = "spring-vote" + environment = "green" + cluster_id = data.aws_ecs_cluster.main.id + subnet_ids = data.aws_subnets.public.ids + security_group_id = data.aws_security_group.web.id + container_image = "your-ecr-repo:green" # TODO: 실제 이미지로 변경 + container_port = 8080 + desired_count = 1 +} + +output "green_service_name" { + value = module.ecs_service_green.service_name +} diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..f68a553 --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,121 @@ +# main.tf - 메인 Terraform 설정 + +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +# AWS Provider 설정 +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "Terraform" + } + } +} + +# VPC 모듈 +module "vpc" { + source = "./modules/vpc" + + project_name = var.project_name + environment = var.environment + vpc_cidr = var.vpc_cidr +} + +# RDS 모듈 +module "rds" { + source = "./modules/rds" + + project_name = var.project_name + environment = var.environment + vpc_id = module.vpc.vpc_id + private_subnet_ids = module.vpc.private_subnet_ids + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password + db_instance_class = var.db_instance_class + db_security_group_id = module.vpc.db_security_group_id +} + +# EC2 인스턴스 모듈 +module "ec2" { + source = "./modules/ec2_instance" + + project_name = var.project_name + environment = var.environment + instance_type = var.instance_type + key_name = var.key_name + subnet_id = module.vpc.public_subnet_ids[0] + security_group_id = module.vpc.web_security_group_id +} + +# ECS 클러스터 모듈 +module "ecs_cluster" { + source = "./modules/ecs_cluster" + + project_name = var.project_name + environment = var.environment +} + +# ECS 서비스 모듈 +module "ecs_service" { + source = "./modules/ecs_service" + + project_name = var.project_name + environment = var.environment + cluster_id = module.ecs_cluster.cluster_id + subnet_ids = module.vpc.public_subnet_ids + security_group_id = module.vpc.web_security_group_id +} + +# Outputs +output "vpc_id" { + description = "VPC ID" + value = module.vpc.vpc_id +} + +output "ec2_public_ip" { + description = "EC2 퍼블릭 IP" + value = module.ec2.public_ip +} + +output "rds_endpoint" { + description = "RDS 엔드포인트" + value = module.rds.endpoint +} + +output "ecs_cluster_name" { + description = "ECS 클러스터 이름" + value = module.ecs_cluster.cluster_name +} + +# ECR 리포지토리 +resource "aws_ecr_repository" "main" { + name = "${var.project_name}-${var.environment}" + image_tag_mutability = "MUTABLE" + force_delete = true + + image_scanning_configuration { + scan_on_push = true + } + + tags = { + Name = "${var.project_name}-${var.environment}-ecr" + } +} + +output "ecr_repository_url" { + description = "ECR 리포지토리 URL" + value = aws_ecr_repository.main.repository_url +} \ No newline at end of file diff --git a/infra/modules/ec2_instance/main.tf b/infra/modules/ec2_instance/main.tf new file mode 100644 index 0000000..fd3707e --- /dev/null +++ b/infra/modules/ec2_instance/main.tf @@ -0,0 +1,69 @@ +# modules/ec2_instance/main.tf - EC2 인스턴스 설정 + +# Amazon Linux 2023 AMI +data "aws_ami" "amazon_linux" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# EC2 인스턴스 +resource "aws_instance" "main" { + ami = data.aws_ami.amazon_linux.id + instance_type = var.instance_type + key_name = var.key_name + subnet_id = var.subnet_id + vpc_security_group_ids = [var.security_group_id] + + associate_public_ip_address = true + + root_block_device { + volume_type = "gp3" + volume_size = 30 + delete_on_termination = true + encrypted = true + } + + user_data = <<-EOF + #!/bin/bash + yum update -y + yum install -y docker + systemctl start docker + systemctl enable docker + usermod -a -G docker ec2-user + + # Docker Compose 설치 + curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose + + # Java 17 설치 + yum install -y java-17-amazon-corretto-headless + EOF + + tags = { + Name = "${var.project_name}-${var.environment}-instance" + } + + lifecycle { + create_before_destroy = true + } +} + +# Elastic IP (선택사항) +resource "aws_eip" "main" { + instance = aws_instance.main.id + domain = "vpc" + + tags = { + Name = "${var.project_name}-${var.environment}-eip" + } +} diff --git a/infra/modules/ec2_instance/outputs.tf b/infra/modules/ec2_instance/outputs.tf new file mode 100644 index 0000000..da74a93 --- /dev/null +++ b/infra/modules/ec2_instance/outputs.tf @@ -0,0 +1,21 @@ +# modules/ec2_instance/outputs.tf + +output "instance_id" { + description = "EC2 인스턴스 ID" + value = aws_instance.main.id +} + +output "public_ip" { + description = "퍼블릭 IP" + value = aws_eip.main.public_ip +} + +output "private_ip" { + description = "프라이빗 IP" + value = aws_instance.main.private_ip +} + +output "public_dns" { + description = "퍼블릭 DNS" + value = aws_instance.main.public_dns +} diff --git a/infra/modules/ec2_instance/variables.tf b/infra/modules/ec2_instance/variables.tf new file mode 100644 index 0000000..0fdba6d --- /dev/null +++ b/infra/modules/ec2_instance/variables.tf @@ -0,0 +1,32 @@ +# modules/ec2_instance/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "instance_type" { + description = "EC2 인스턴스 타입" + type = string + default = "t2.nano" +} + +variable "key_name" { + description = "SSH 키 페어 이름" + type = string +} + +variable "subnet_id" { + description = "서브넷 ID" + type = string +} + +variable "security_group_id" { + description = "보안 그룹 ID" + type = string +} diff --git a/infra/modules/ecs_cluster/main.tf b/infra/modules/ecs_cluster/main.tf new file mode 100644 index 0000000..9f001bd --- /dev/null +++ b/infra/modules/ecs_cluster/main.tf @@ -0,0 +1,28 @@ +# modules/ecs_cluster/main.tf - ECS 클러스터 설정 + +# ECS 클러스터 +resource "aws_ecs_cluster" "main" { + name = "${var.project_name}-${var.environment}-cluster" + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = { + Name = "${var.project_name}-${var.environment}-cluster" + } +} + +# ECS 클러스터 용량 공급자 +resource "aws_ecs_cluster_capacity_providers" "main" { + cluster_name = aws_ecs_cluster.main.name + + capacity_providers = ["FARGATE", "FARGATE_SPOT"] + + default_capacity_provider_strategy { + base = 1 + weight = 100 + capacity_provider = "FARGATE" + } +} diff --git a/infra/modules/ecs_cluster/outputs.tf b/infra/modules/ecs_cluster/outputs.tf new file mode 100644 index 0000000..6095e71 --- /dev/null +++ b/infra/modules/ecs_cluster/outputs.tf @@ -0,0 +1,16 @@ +# modules/ecs_cluster/outputs.tf + +output "cluster_id" { + description = "ECS 클러스터 ID" + value = aws_ecs_cluster.main.id +} + +output "cluster_arn" { + description = "ECS 클러스터 ARN" + value = aws_ecs_cluster.main.arn +} + +output "cluster_name" { + description = "ECS 클러스터 이름" + value = aws_ecs_cluster.main.name +} diff --git a/infra/modules/ecs_cluster/variables.tf b/infra/modules/ecs_cluster/variables.tf new file mode 100644 index 0000000..1b64da7 --- /dev/null +++ b/infra/modules/ecs_cluster/variables.tf @@ -0,0 +1,11 @@ +# modules/ecs_cluster/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} diff --git a/infra/modules/ecs_service/main.tf b/infra/modules/ecs_service/main.tf new file mode 100644 index 0000000..9372d9f --- /dev/null +++ b/infra/modules/ecs_service/main.tf @@ -0,0 +1,137 @@ +# modules/ecs_service/main.tf - ECS 서비스 설정 + +# CloudWatch 로그 그룹 +resource "aws_cloudwatch_log_group" "main" { + name = "/ecs/${var.project_name}-${var.environment}" + retention_in_days = 7 + + tags = { + Name = "${var.project_name}-${var.environment}-logs" + } +} + +# ECS Task Execution Role +resource "aws_iam_role" "ecs_task_execution" { + name = "${var.project_name}-${var.environment}-ecs-task-execution" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${var.project_name}-${var.environment}-ecs-task-execution" + } +} + +resource "aws_iam_role_policy_attachment" "ecs_task_execution" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +# ECS Task Role +resource "aws_iam_role" "ecs_task" { + name = "${var.project_name}-${var.environment}-ecs-task" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${var.project_name}-${var.environment}-ecs-task" + } +} + +# ECS Task Definition +resource "aws_ecs_task_definition" "main" { + family = "${var.project_name}-${var.environment}" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.task_cpu + memory = var.task_memory + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([ + { + name = "${var.project_name}-${var.environment}" + image = var.container_image + + portMappings = [ + { + containerPort = var.container_port + hostPort = var.container_port + protocol = "tcp" + } + ] + + environment = [ + { + name = "SPRING_PROFILES_ACTIVE" + value = var.environment + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.main.name + "awslogs-region" = data.aws_region.current.name + "awslogs-stream-prefix" = "ecs" + } + } + + essential = true + } + ]) + + tags = { + Name = "${var.project_name}-${var.environment}-task" + } +} + +# 현재 리전 +data "aws_region" "current" {} + +# ECS Service +resource "aws_ecs_service" "main" { + name = "${var.project_name}-${var.environment}-service" + cluster = var.cluster_id + task_definition = aws_ecs_task_definition.main.arn + desired_count = var.desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = var.subnet_ids + security_groups = [var.security_group_id] + assign_public_ip = true + } + + deployment_maximum_percent = 200 + deployment_minimum_healthy_percent = 100 + + lifecycle { + ignore_changes = [desired_count] + } + + tags = { + Name = "${var.project_name}-${var.environment}-service" + } +} \ No newline at end of file diff --git a/infra/modules/ecs_service/outputs.tf b/infra/modules/ecs_service/outputs.tf new file mode 100644 index 0000000..92a932f --- /dev/null +++ b/infra/modules/ecs_service/outputs.tf @@ -0,0 +1,21 @@ +# modules/ecs_service/outputs.tf + +output "service_id" { + description = "ECS 서비스 ID" + value = aws_ecs_service.main.id +} + +output "service_name" { + description = "ECS 서비스 이름" + value = aws_ecs_service.main.name +} + +output "task_definition_arn" { + description = "Task Definition ARN" + value = aws_ecs_task_definition.main.arn +} + +output "log_group_name" { + description = "CloudWatch 로그 그룹 이름" + value = aws_cloudwatch_log_group.main.name +} diff --git a/infra/modules/ecs_service/variables.tf b/infra/modules/ecs_service/variables.tf new file mode 100644 index 0000000..b628e8e --- /dev/null +++ b/infra/modules/ecs_service/variables.tf @@ -0,0 +1,56 @@ +# modules/ecs_service/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "cluster_id" { + description = "ECS 클러스터 ID" + type = string +} + +variable "subnet_ids" { + description = "서브넷 ID 목록" + type = list(string) +} + +variable "security_group_id" { + description = "보안 그룹 ID" + type = string +} + +variable "container_image" { + description = "컨테이너 이미지" + type = string + default = "nginx:latest" # TODO: 실제 이미지로 변경 +} + +variable "container_port" { + description = "컨테이너 포트" + type = number + default = 8080 +} + +variable "task_cpu" { + description = "Task CPU" + type = string + default = "256" +} + +variable "task_memory" { + description = "Task 메모리" + type = string + default = "512" +} + +variable "desired_count" { + description = "원하는 태스크 수" + type = number + default = 1 +} diff --git a/infra/modules/rds/main.tf b/infra/modules/rds/main.tf new file mode 100644 index 0000000..60a8c02 --- /dev/null +++ b/infra/modules/rds/main.tf @@ -0,0 +1,76 @@ +# modules/rds/main.tf - PostgreSQL RDS 설정 + +# DB 서브넷 그룹 +resource "aws_db_subnet_group" "main" { + name = "${var.project_name}-${var.environment}-db-subnet-group" + description = "Database subnet group" + subnet_ids = var.private_subnet_ids + + tags = { + Name = "${var.project_name}-${var.environment}-db-subnet-group" + } +} + +# RDS PostgreSQL 인스턴스 +resource "aws_db_instance" "main" { + identifier = "${var.project_name}-${var.environment}-db" + + # 엔진 설정 + engine = "postgres" + engine_version = "15.12" + instance_class = var.db_instance_class + allocated_storage = 20 + max_allocated_storage = 100 + storage_type = "gp2" + storage_encrypted = true + + # 데이터베이스 설정 + db_name = var.db_name + username = var.db_username + password = var.db_password + port = 5432 + + # 네트워크 설정 + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [var.db_security_group_id] + publicly_accessible = false + multi_az = false # 비용 절감을 위해 단일 AZ + + # 백업 설정 + backup_retention_period = 7 + backup_window = "03:00-04:00" + maintenance_window = "Mon:04:00-Mon:05:00" + + # 기타 설정 + skip_final_snapshot = true # 개발환경용 + final_snapshot_identifier = "${var.project_name}-${var.environment}-final-snapshot" + deletion_protection = false # 개발환경용 + auto_minor_version_upgrade = true + + # 파라미터 그룹 + parameter_group_name = aws_db_parameter_group.main.name + + tags = { + Name = "${var.project_name}-${var.environment}-db" + } +} + +# DB 파라미터 그룹 +resource "aws_db_parameter_group" "main" { + name = "${var.project_name}-${var.environment}-pg15" + family = "postgres15" + + parameter { + name = "log_connections" + value = "1" + } + + parameter { + name = "log_disconnections" + value = "1" + } + + tags = { + Name = "${var.project_name}-${var.environment}-pg15" + } +} diff --git a/infra/modules/rds/outputs.tf b/infra/modules/rds/outputs.tf new file mode 100644 index 0000000..af9787b --- /dev/null +++ b/infra/modules/rds/outputs.tf @@ -0,0 +1,26 @@ +# modules/rds/outputs.tf + +output "endpoint" { + description = "RDS 엔드포인트" + value = aws_db_instance.main.endpoint +} + +output "address" { + description = "RDS 주소 (포트 제외)" + value = aws_db_instance.main.address +} + +output "port" { + description = "RDS 포트" + value = aws_db_instance.main.port +} + +output "db_name" { + description = "데이터베이스 이름" + value = aws_db_instance.main.db_name +} + +output "db_instance_id" { + description = "RDS 인스턴스 ID" + value = aws_db_instance.main.id +} diff --git a/infra/modules/rds/variables.tf b/infra/modules/rds/variables.tf new file mode 100644 index 0000000..1692ed0 --- /dev/null +++ b/infra/modules/rds/variables.tf @@ -0,0 +1,48 @@ +# modules/rds/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "private_subnet_ids" { + description = "프라이빗 서브넷 ID 목록" + type = list(string) +} + +variable "db_security_group_id" { + description = "DB 보안 그룹 ID" + type = string +} + +variable "db_name" { + description = "데이터베이스 이름" + type = string +} + +variable "db_username" { + description = "데이터베이스 사용자명" + type = string +} + +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} + +variable "db_instance_class" { + description = "RDS 인스턴스 클래스" + type = string + default = "db.t3.micro" +} diff --git a/infra/modules/vpc/main.tf b/infra/modules/vpc/main.tf new file mode 100644 index 0000000..ba46912 --- /dev/null +++ b/infra/modules/vpc/main.tf @@ -0,0 +1,148 @@ +# modules/vpc/main.tf - VPC 및 네트워크 설정 + +# 가용 영역 데이터 +data "aws_availability_zones" "available" { + state = "available" +} + +# VPC +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${var.project_name}-${var.environment}-vpc" + } +} + +# 인터넷 게이트웨이 +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.project_name}-${var.environment}-igw" + } +} + +# 퍼블릭 서브넷 (2개 AZ) +resource "aws_subnet" "public" { + count = 2 + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) + availability_zone = data.aws_availability_zones.available.names[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "${var.project_name}-${var.environment}-public-${count.index + 1}" + Type = "Public" + } +} + +# 프라이빗 서브넷 (RDS용, 2개 AZ) +resource "aws_subnet" "private" { + count = 2 + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10) + availability_zone = data.aws_availability_zones.available.names[count.index] + + tags = { + Name = "${var.project_name}-${var.environment}-private-${count.index + 1}" + Type = "Private" + } +} + +# 퍼블릭 라우트 테이블 +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "${var.project_name}-${var.environment}-public-rt" + } +} + +# 퍼블릭 서브넷 라우트 테이블 연결 +resource "aws_route_table_association" "public" { + count = 2 + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +# 웹 서버용 보안 그룹 (HTTP, HTTPS, SSH) +resource "aws_security_group" "web" { + name = "${var.project_name}-${var.environment}-web-sg" + description = "Security group for web servers" + vpc_id = aws_vpc.main.id + + # SSH (22) + ingress { + description = "SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTP (80) + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTPS (443) + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # 모든 아웃바운드 허용 + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-${var.environment}-web-sg" + } +} + +# RDS용 보안 그룹 (PostgreSQL) +resource "aws_security_group" "db" { + name = "${var.project_name}-${var.environment}-db-sg" + description = "Security group for RDS PostgreSQL" + vpc_id = aws_vpc.main.id + + # PostgreSQL (5432) - 웹 서버에서만 접근 + ingress { + description = "PostgreSQL from web servers" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.web.id] + } + + # 모든 아웃바운드 허용 + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-${var.environment}-db-sg" + } +} diff --git a/infra/modules/vpc/outputs.tf b/infra/modules/vpc/outputs.tf new file mode 100644 index 0000000..b996b52 --- /dev/null +++ b/infra/modules/vpc/outputs.tf @@ -0,0 +1,31 @@ +# modules/vpc/outputs.tf + +output "vpc_id" { + description = "VPC ID" + value = aws_vpc.main.id +} + +output "public_subnet_ids" { + description = "퍼블릭 서브넷 ID 목록" + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + description = "프라이빗 서브넷 ID 목록" + value = aws_subnet.private[*].id +} + +output "web_security_group_id" { + description = "웹 서버 보안 그룹 ID" + value = aws_security_group.web.id +} + +output "db_security_group_id" { + description = "DB 보안 그룹 ID" + value = aws_security_group.db.id +} + +output "internet_gateway_id" { + description = "인터넷 게이트웨이 ID" + value = aws_internet_gateway.main.id +} diff --git a/infra/modules/vpc/variables.tf b/infra/modules/vpc/variables.tf new file mode 100644 index 0000000..3b0e024 --- /dev/null +++ b/infra/modules/vpc/variables.tf @@ -0,0 +1,17 @@ +# modules/vpc/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "vpc_cidr" { + description = "VPC CIDR 블록" + type = string + default = "10.0.0.0/16" +} diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example new file mode 100644 index 0000000..01886d3 --- /dev/null +++ b/infra/terraform.tfvars.example @@ -0,0 +1,18 @@ +# terraform.tfvars - 변수 값 설정 + +aws_region = "ap-northeast-2" +project_name = "spring-vote" +environment = "dev" + +# VPC +vpc_cidr = "10.0.0.0/16" + +# EC2 +instance_type = "t2.nano" +key_name = "your-key-name" # TODO: 실제 키 페어 이름으로 변경 + +# RDS +db_name = "springvote" +db_username = "admin" +db_password = "YourSecurePassword123!" # TODO: 실제 비밀번호로 변경 +db_instance_class = "db.t3.micro" diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..f27fbe2 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,63 @@ +# variables.tf - 전역 변수 정의 + +variable "aws_region" { + description = "AWS 리전" + type = string + default = "ap-northeast-2" +} + +variable "project_name" { + description = "프로젝트 이름" + type = string + default = "spring-vote" +} + +variable "environment" { + description = "환경 (dev, staging, prod)" + type = string + default = "dev" +} + +# VPC 관련 +variable "vpc_cidr" { + description = "VPC CIDR 블록" + type = string + default = "10.0.0.0/16" +} + +# EC2 관련 +variable "instance_type" { + description = "EC2 인스턴스 타입" + type = string + default = "t2.nano" +} + +variable "key_name" { + description = "SSH 키 페어 이름" + type = string +} + +# RDS 관련 +variable "db_name" { + description = "데이터베이스 이름" + type = string + default = "springvote" +} + +variable "db_username" { + description = "데이터베이스 사용자명" + type = string + default = "admin" +} + +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} + +variable "db_instance_class" { + description = "RDS 인스턴스 클래스" + type = string + default = "db.t3.micro" +} diff --git a/settings.gradle b/settings.gradle index bfce6e0..550be7a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'vote' +rootProject.name = 'spring-vote-22nd' \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/enums/Part.java b/src/main/java/com/diggindie/vote/common/enums/Part.java new file mode 100644 index 0000000..56f7ac0 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/enums/Part.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.common.enums; + +public enum Part { + BACKEND, + FRONTEND +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java b/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java new file mode 100644 index 0000000..7f855f0 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java @@ -0,0 +1,25 @@ +package com.diggindie.vote.domain.member.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "candidate") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Candidate { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "candidate_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false, unique = true) + private Member member; + + public Candidate(Member member) { + this.member = member; + } + +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java new file mode 100644 index 0000000..76634df --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -0,0 +1,42 @@ +package com.diggindie.vote.domain.member.domain; + +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.domain.team.domain.Team; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "member") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + private Team team; + + @Enumerated(EnumType.STRING) + @Column(name = "part", nullable = false, length = 20) + private Part part; + + @Column(name = "login_id", nullable = false, unique = true, length = 20) + private String loginId; + + @Column(name = "email", nullable = false, unique = true, length = 50) + private String email; + + @Column(name = "password", nullable = false, length = 100) + private String password; + + @Column(name = "membername", nullable = false, length = 10) + private String memberName; + + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private Candidate candidate; + +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java new file mode 100644 index 0000000..5c4afba --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java @@ -0,0 +1,28 @@ +package com.diggindie.vote.domain.team.domain; + +import com.diggindie.vote.domain.member.domain.Member; +import jakarta.persistence.*; +import lombok.*; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "team") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Team { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_id") + private Long id; + + @Column(name = "teamname", nullable = false, length = 20) + private String teamName; + + @Column(name = "proposal", length = 200) + private String proposal; + + @OneToMany(mappedBy = "team") + private List members = new ArrayList<>(); +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java new file mode 100644 index 0000000..e71574c --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java @@ -0,0 +1,28 @@ +package com.diggindie.vote.domain.vote.domain; + +import com.diggindie.vote.domain.member.domain.Candidate; +import com.diggindie.vote.domain.member.domain.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "part_vote", indexes = { + @Index(name = "idx_part_vote_candidate", columnList = "candidate_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PartVote { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "part_vote_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voter_member_id", nullable = false, unique = true) + private Member voter; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "candidate_id", nullable = false) + private Candidate candidate; +} diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java new file mode 100644 index 0000000..6d94a1d --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java @@ -0,0 +1,29 @@ +package com.diggindie.vote.domain.vote.domain; + +import com.diggindie.vote.domain.team.domain.Team; +import com.diggindie.vote.domain.member.domain.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "team_vote", indexes = { + @Index(name = "idx_team_vote_team", columnList = "team_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamVote { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_vote_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voter_member_id", nullable = false, unique = true) + private Member voter; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + private Team team; +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3761c62..4bd15c5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,23 @@ spring: application: name: vote + + datasource: + url: jdbc:mysql://localhost:3306/vote?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true + username: root + password: 로컬비번 !! + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type.descriptor.sql: trace \ No newline at end of file From b32f58e18d90da2673596b3daeb458cd651d5fab Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 00:41:53 +0900 Subject: [PATCH 14/52] =?UTF-8?q?chore:=20workflow=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/{ => workflows}/deploy.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ => workflows}/deploy.yml (100%) diff --git a/.github/deploy.yml b/.github/workflows/deploy.yml similarity index 100% rename from .github/deploy.yml rename to .github/workflows/deploy.yml From 875225ddb49bfcae794ec4087e378f01c4b642f8 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 00:42:40 +0900 Subject: [PATCH 15/52] =?UTF-8?q?chore:=20workflow=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/{ => workflows}/deploy.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ => workflows}/deploy.yml (100%) diff --git a/.github/deploy.yml b/.github/workflows/deploy.yml similarity index 100% rename from .github/deploy.yml rename to .github/workflows/deploy.yml From ebff0743da8b9fee8e2dc3c3b65d61144c241707 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 00:50:08 +0900 Subject: [PATCH 16/52] =?UTF-8?q?fix:=20terraform=208080=20=EC=9D=B8?= =?UTF-8?q?=EB=B0=94=EC=9A=B4=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/modules/vpc/main.tf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/infra/modules/vpc/main.tf b/infra/modules/vpc/main.tf index ba46912..7fbbb56 100644 --- a/infra/modules/vpc/main.tf +++ b/infra/modules/vpc/main.tf @@ -106,6 +106,14 @@ resource "aws_security_group" "web" { cidr_blocks = ["0.0.0.0/0"] } + ingress { + description = "Spring Boot" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + # 모든 아웃바운드 허용 egress { from_port = 0 From f3da455a8331b85c87fa2a3355e58fb544e11c76 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 00:50:33 +0900 Subject: [PATCH 17/52] =?UTF-8?q?fix:=20terraform=208080=20=EC=9D=B8?= =?UTF-8?q?=EB=B0=94=EC=9A=B4=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/modules/vpc/main.tf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/infra/modules/vpc/main.tf b/infra/modules/vpc/main.tf index ba46912..7fbbb56 100644 --- a/infra/modules/vpc/main.tf +++ b/infra/modules/vpc/main.tf @@ -106,6 +106,14 @@ resource "aws_security_group" "web" { cidr_blocks = ["0.0.0.0/0"] } + ingress { + description = "Spring Boot" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + # 모든 아웃바운드 허용 egress { from_port = 0 From 4fb67de06ba21a8169988d70eef6f03dceae8284 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 01:09:26 +0900 Subject: [PATCH 18/52] =?UTF-8?q?refactor:=20ecs=20=EC=97=86=EC=9D=B4=20ec?= =?UTF-8?q?2=EB=A1=9C=EB=A7=8C=20=EB=B0=B0=ED=8F=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/blue/main.tf | 78 +++++++++----- infra/green/main.tf | 80 ++++++++++----- infra/main.tf | 24 ----- infra/modules/ecs_cluster/main.tf | 28 ----- infra/modules/ecs_cluster/outputs.tf | 16 --- infra/modules/ecs_cluster/variables.tf | 11 -- infra/modules/ecs_service/main.tf | 137 ------------------------- infra/modules/ecs_service/outputs.tf | 21 ---- infra/modules/ecs_service/variables.tf | 56 ---------- 9 files changed, 109 insertions(+), 342 deletions(-) delete mode 100644 infra/modules/ecs_cluster/main.tf delete mode 100644 infra/modules/ecs_cluster/outputs.tf delete mode 100644 infra/modules/ecs_cluster/variables.tf delete mode 100644 infra/modules/ecs_service/main.tf delete mode 100644 infra/modules/ecs_service/outputs.tf delete mode 100644 infra/modules/ecs_service/variables.tf diff --git a/infra/blue/main.tf b/infra/blue/main.tf index e24c484..4f833c0 100644 --- a/infra/blue/main.tf +++ b/infra/blue/main.tf @@ -1,4 +1,4 @@ -# blue/main.tf - Blue 환경 배포 설정 +# blue/main.tf - Blue EC2 환경 terraform { required_version = ">= 1.0.0" @@ -30,15 +30,12 @@ data "aws_vpc" "main" { } } -data "aws_subnets" "public" { - filter { - name = "vpc-id" - values = [data.aws_vpc.main.id] - } +data "aws_subnet" "public" { + vpc_id = data.aws_vpc.main.id filter { - name = "tag:Type" - values = ["Public"] + name = "tag:Name" + values = ["spring-vote-dev-public-1"] } } @@ -51,24 +48,57 @@ data "aws_security_group" "web" { } } -data "aws_ecs_cluster" "main" { - cluster_name = "spring-vote-dev-cluster" +# Amazon Linux 2023 AMI +data "aws_ami" "amazon_linux" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } } -# Blue ECS Service -module "ecs_service_blue" { - source = "../modules/ecs_service" - - project_name = "spring-vote" - environment = "blue" - cluster_id = data.aws_ecs_cluster.main.id - subnet_ids = data.aws_subnets.public.ids - security_group_id = data.aws_security_group.web.id - container_image = "your-ecr-repo:blue" # TODO: 실제 이미지로 변경 - container_port = 8080 - desired_count = 1 +# Blue EC2 인스턴스 +resource "aws_instance" "blue" { + ami = data.aws_ami.amazon_linux.id + instance_type = "t2.nano" + key_name = "terraform" + subnet_id = data.aws_subnet.public.id + vpc_security_group_ids = [data.aws_security_group.web.id] + + associate_public_ip_address = true + + root_block_device { + volume_type = "gp3" + volume_size = 30 + delete_on_termination = true + encrypted = true + } + + user_data = <<-EOF + #!/bin/bash + yum update -y + yum install -y docker + systemctl start docker + systemctl enable docker + usermod -a -G docker ec2-user + EOF + + tags = { + Name = "spring-vote-blue" + } } -output "blue_service_name" { - value = module.ecs_service_blue.service_name +output "blue_instance_id" { + value = aws_instance.blue.id } + +output "blue_public_ip" { + value = aws_instance.blue.public_ip +} \ No newline at end of file diff --git a/infra/green/main.tf b/infra/green/main.tf index c10989d..69f12c6 100644 --- a/infra/green/main.tf +++ b/infra/green/main.tf @@ -1,4 +1,4 @@ -# green/main.tf - Green 환경 배포 설정 +# green/main.tf - Green EC2 환경 terraform { required_version = ">= 1.0.0" @@ -23,22 +23,19 @@ provider "aws" { } } -# 기존 인프라 참조 (Remote State 또는 Data Source 사용) +# 기존 인프라 참조 data "aws_vpc" "main" { tags = { Name = "spring-vote-dev-vpc" } } -data "aws_subnets" "public" { - filter { - name = "vpc-id" - values = [data.aws_vpc.main.id] - } +data "aws_subnet" "public" { + vpc_id = data.aws_vpc.main.id filter { - name = "tag:Type" - values = ["Public"] + name = "tag:Name" + values = ["spring-vote-dev-public-1"] } } @@ -51,24 +48,57 @@ data "aws_security_group" "web" { } } -data "aws_ecs_cluster" "main" { - cluster_name = "spring-vote-dev-cluster" +# Amazon Linux 2023 AMI +data "aws_ami" "amazon_linux" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# Green EC2 인스턴스 +resource "aws_instance" "green" { + ami = data.aws_ami.amazon_linux.id + instance_type = "t2.nano" + key_name = "terraform" + subnet_id = data.aws_subnet.public.id + vpc_security_group_ids = [data.aws_security_group.web.id] + + associate_public_ip_address = true + + root_block_device { + volume_type = "gp3" + volume_size = 30 + delete_on_termination = true + encrypted = true + } + + user_data = <<-EOF + #!/bin/bash + yum update -y + yum install -y docker + systemctl start docker + systemctl enable docker + usermod -a -G docker ec2-user + EOF + + tags = { + Name = "spring-vote-green" + } } -# Green ECS Service -module "ecs_service_green" { - source = "../modules/ecs_service" - - project_name = "spring-vote" - environment = "green" - cluster_id = data.aws_ecs_cluster.main.id - subnet_ids = data.aws_subnets.public.ids - security_group_id = data.aws_security_group.web.id - container_image = "your-ecr-repo:green" # TODO: 실제 이미지로 변경 - container_port = 8080 - desired_count = 1 +output "green_instance_id" { + value = aws_instance.green.id } -output "green_service_name" { - value = module.ecs_service_green.service_name +output "green_public_ip" { + value = aws_instance.green.public_ip } diff --git a/infra/main.tf b/infra/main.tf index f68a553..3792012 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -60,25 +60,6 @@ module "ec2" { security_group_id = module.vpc.web_security_group_id } -# ECS 클러스터 모듈 -module "ecs_cluster" { - source = "./modules/ecs_cluster" - - project_name = var.project_name - environment = var.environment -} - -# ECS 서비스 모듈 -module "ecs_service" { - source = "./modules/ecs_service" - - project_name = var.project_name - environment = var.environment - cluster_id = module.ecs_cluster.cluster_id - subnet_ids = module.vpc.public_subnet_ids - security_group_id = module.vpc.web_security_group_id -} - # Outputs output "vpc_id" { description = "VPC ID" @@ -95,11 +76,6 @@ output "rds_endpoint" { value = module.rds.endpoint } -output "ecs_cluster_name" { - description = "ECS 클러스터 이름" - value = module.ecs_cluster.cluster_name -} - # ECR 리포지토리 resource "aws_ecr_repository" "main" { name = "${var.project_name}-${var.environment}" diff --git a/infra/modules/ecs_cluster/main.tf b/infra/modules/ecs_cluster/main.tf deleted file mode 100644 index 9f001bd..0000000 --- a/infra/modules/ecs_cluster/main.tf +++ /dev/null @@ -1,28 +0,0 @@ -# modules/ecs_cluster/main.tf - ECS 클러스터 설정 - -# ECS 클러스터 -resource "aws_ecs_cluster" "main" { - name = "${var.project_name}-${var.environment}-cluster" - - setting { - name = "containerInsights" - value = "enabled" - } - - tags = { - Name = "${var.project_name}-${var.environment}-cluster" - } -} - -# ECS 클러스터 용량 공급자 -resource "aws_ecs_cluster_capacity_providers" "main" { - cluster_name = aws_ecs_cluster.main.name - - capacity_providers = ["FARGATE", "FARGATE_SPOT"] - - default_capacity_provider_strategy { - base = 1 - weight = 100 - capacity_provider = "FARGATE" - } -} diff --git a/infra/modules/ecs_cluster/outputs.tf b/infra/modules/ecs_cluster/outputs.tf deleted file mode 100644 index 6095e71..0000000 --- a/infra/modules/ecs_cluster/outputs.tf +++ /dev/null @@ -1,16 +0,0 @@ -# modules/ecs_cluster/outputs.tf - -output "cluster_id" { - description = "ECS 클러스터 ID" - value = aws_ecs_cluster.main.id -} - -output "cluster_arn" { - description = "ECS 클러스터 ARN" - value = aws_ecs_cluster.main.arn -} - -output "cluster_name" { - description = "ECS 클러스터 이름" - value = aws_ecs_cluster.main.name -} diff --git a/infra/modules/ecs_cluster/variables.tf b/infra/modules/ecs_cluster/variables.tf deleted file mode 100644 index 1b64da7..0000000 --- a/infra/modules/ecs_cluster/variables.tf +++ /dev/null @@ -1,11 +0,0 @@ -# modules/ecs_cluster/variables.tf - -variable "project_name" { - description = "프로젝트 이름" - type = string -} - -variable "environment" { - description = "환경" - type = string -} diff --git a/infra/modules/ecs_service/main.tf b/infra/modules/ecs_service/main.tf deleted file mode 100644 index 9372d9f..0000000 --- a/infra/modules/ecs_service/main.tf +++ /dev/null @@ -1,137 +0,0 @@ -# modules/ecs_service/main.tf - ECS 서비스 설정 - -# CloudWatch 로그 그룹 -resource "aws_cloudwatch_log_group" "main" { - name = "/ecs/${var.project_name}-${var.environment}" - retention_in_days = 7 - - tags = { - Name = "${var.project_name}-${var.environment}-logs" - } -} - -# ECS Task Execution Role -resource "aws_iam_role" "ecs_task_execution" { - name = "${var.project_name}-${var.environment}-ecs-task-execution" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "ecs-tasks.amazonaws.com" - } - } - ] - }) - - tags = { - Name = "${var.project_name}-${var.environment}-ecs-task-execution" - } -} - -resource "aws_iam_role_policy_attachment" "ecs_task_execution" { - role = aws_iam_role.ecs_task_execution.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" -} - -# ECS Task Role -resource "aws_iam_role" "ecs_task" { - name = "${var.project_name}-${var.environment}-ecs-task" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "ecs-tasks.amazonaws.com" - } - } - ] - }) - - tags = { - Name = "${var.project_name}-${var.environment}-ecs-task" - } -} - -# ECS Task Definition -resource "aws_ecs_task_definition" "main" { - family = "${var.project_name}-${var.environment}" - network_mode = "awsvpc" - requires_compatibilities = ["FARGATE"] - cpu = var.task_cpu - memory = var.task_memory - execution_role_arn = aws_iam_role.ecs_task_execution.arn - task_role_arn = aws_iam_role.ecs_task.arn - - container_definitions = jsonencode([ - { - name = "${var.project_name}-${var.environment}" - image = var.container_image - - portMappings = [ - { - containerPort = var.container_port - hostPort = var.container_port - protocol = "tcp" - } - ] - - environment = [ - { - name = "SPRING_PROFILES_ACTIVE" - value = var.environment - } - ] - - logConfiguration = { - logDriver = "awslogs" - options = { - "awslogs-group" = aws_cloudwatch_log_group.main.name - "awslogs-region" = data.aws_region.current.name - "awslogs-stream-prefix" = "ecs" - } - } - - essential = true - } - ]) - - tags = { - Name = "${var.project_name}-${var.environment}-task" - } -} - -# 현재 리전 -data "aws_region" "current" {} - -# ECS Service -resource "aws_ecs_service" "main" { - name = "${var.project_name}-${var.environment}-service" - cluster = var.cluster_id - task_definition = aws_ecs_task_definition.main.arn - desired_count = var.desired_count - launch_type = "FARGATE" - - network_configuration { - subnets = var.subnet_ids - security_groups = [var.security_group_id] - assign_public_ip = true - } - - deployment_maximum_percent = 200 - deployment_minimum_healthy_percent = 100 - - lifecycle { - ignore_changes = [desired_count] - } - - tags = { - Name = "${var.project_name}-${var.environment}-service" - } -} \ No newline at end of file diff --git a/infra/modules/ecs_service/outputs.tf b/infra/modules/ecs_service/outputs.tf deleted file mode 100644 index 92a932f..0000000 --- a/infra/modules/ecs_service/outputs.tf +++ /dev/null @@ -1,21 +0,0 @@ -# modules/ecs_service/outputs.tf - -output "service_id" { - description = "ECS 서비스 ID" - value = aws_ecs_service.main.id -} - -output "service_name" { - description = "ECS 서비스 이름" - value = aws_ecs_service.main.name -} - -output "task_definition_arn" { - description = "Task Definition ARN" - value = aws_ecs_task_definition.main.arn -} - -output "log_group_name" { - description = "CloudWatch 로그 그룹 이름" - value = aws_cloudwatch_log_group.main.name -} diff --git a/infra/modules/ecs_service/variables.tf b/infra/modules/ecs_service/variables.tf deleted file mode 100644 index b628e8e..0000000 --- a/infra/modules/ecs_service/variables.tf +++ /dev/null @@ -1,56 +0,0 @@ -# modules/ecs_service/variables.tf - -variable "project_name" { - description = "프로젝트 이름" - type = string -} - -variable "environment" { - description = "환경" - type = string -} - -variable "cluster_id" { - description = "ECS 클러스터 ID" - type = string -} - -variable "subnet_ids" { - description = "서브넷 ID 목록" - type = list(string) -} - -variable "security_group_id" { - description = "보안 그룹 ID" - type = string -} - -variable "container_image" { - description = "컨테이너 이미지" - type = string - default = "nginx:latest" # TODO: 실제 이미지로 변경 -} - -variable "container_port" { - description = "컨테이너 포트" - type = number - default = 8080 -} - -variable "task_cpu" { - description = "Task CPU" - type = string - default = "256" -} - -variable "task_memory" { - description = "Task 메모리" - type = string - default = "512" -} - -variable "desired_count" { - description = "원하는 태스크 수" - type = number - default = 1 -} From be3a05b085021ad8e408aea4155540f4f5fc4baf Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 01:17:07 +0900 Subject: [PATCH 19/52] =?UTF-8?q?refactor:=20ecs=20=EB=8C=80=EC=8B=A0=20ec?= =?UTF-8?q?2=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 402d53d..e4f5090 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,8 +8,7 @@ on: env: AWS_REGION: ap-northeast-2 ECR_REPOSITORY: spring-vote-dev - ECS_CLUSTER: spring-vote-dev-cluster - ECS_SERVICE: spring-vote-dev-service + EC2_HOST: ${{ secrets.EC2_HOST }} jobs: deploy: @@ -49,9 +48,15 @@ jobs: docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest - - name: Deploy to ECS - run: | - aws ecs update-service \ - --cluster $ECS_CLUSTER \ - --service $ECS_SERVICE \ - --force-new-deployment \ No newline at end of file + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ec2-user + key: ${{ secrets.EC2_SSH_KEY }} + script: | + aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com + docker pull ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest + docker stop spring-vote || true + docker rm spring-vote || true + docker run -d --name spring-vote -p 8080:8080 ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest From 72bbb96a61ce1412f62bd6fe60991194375b904b Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 01:29:26 +0900 Subject: [PATCH 20/52] chore: merge conflict --- infra/main.tf | 68 ++++++++++++++------------------------------------- 1 file changed, 19 insertions(+), 49 deletions(-) diff --git a/infra/main.tf b/infra/main.tf index 647d814..8273644 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -37,14 +37,14 @@ module "vpc" { module "rds" { source = "./modules/rds" - project_name = var.project_name - environment = var.environment - vpc_id = module.vpc.vpc_id - private_subnet_ids = module.vpc.private_subnet_ids - db_name = var.db_name - db_username = var.db_username - db_password = var.db_password - db_instance_class = var.db_instance_class + project_name = var.project_name + environment = var.environment + vpc_id = module.vpc.vpc_id + private_subnet_ids = module.vpc.private_subnet_ids + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password + db_instance_class = var.db_instance_class db_security_group_id = module.vpc.db_security_group_id } @@ -60,28 +60,21 @@ module "ec2" { security_group_id = module.vpc.web_security_group_id } -<<<<<<< HEAD -# ECS 클러스터 모듈 -module "ecs_cluster" { - source = "./modules/ecs_cluster" - - project_name = var.project_name - environment = var.environment -} +# ECR 리포지토리 +resource "aws_ecr_repository" "main" { + name = "${var.project_name}-${var.environment}" + image_tag_mutability = "MUTABLE" + force_delete = true -# ECS 서비스 모듈 -module "ecs_service" { - source = "./modules/ecs_service" + image_scanning_configuration { + scan_on_push = true + } - project_name = var.project_name - environment = var.environment - cluster_id = module.ecs_cluster.cluster_id - subnet_ids = module.vpc.public_subnet_ids - security_group_id = module.vpc.web_security_group_id + tags = { + Name = "${var.project_name}-${var.environment}-ecr" + } } -======= ->>>>>>> dev # Outputs output "vpc_id" { description = "VPC ID" @@ -98,29 +91,6 @@ output "rds_endpoint" { value = module.rds.endpoint } -<<<<<<< HEAD -output "ecs_cluster_name" { - description = "ECS 클러스터 이름" - value = module.ecs_cluster.cluster_name -} - -======= ->>>>>>> dev -# ECR 리포지토리 -resource "aws_ecr_repository" "main" { - name = "${var.project_name}-${var.environment}" - image_tag_mutability = "MUTABLE" - force_delete = true - - image_scanning_configuration { - scan_on_push = true - } - - tags = { - Name = "${var.project_name}-${var.environment}-ecr" - } -} - output "ecr_repository_url" { description = "ECR 리포지토리 URL" value = aws_ecr_repository.main.repository_url From 6f6336a268f1623301e2cfdc6cb5be654433ab78 Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Wed, 17 Dec 2025 01:30:50 +0900 Subject: [PATCH 21/52] =?UTF-8?q?feat=20:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + build.gradle | 28 +++++++++++++++++++++------- src/main/resources/application.yml | 25 +++++++++++++++++-------- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 983a5f7..d607a29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ HELP.md +.env .gradle build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/build.gradle b/build.gradle index f614035..c7f5870 100644 --- a/build.gradle +++ b/build.gradle @@ -25,15 +25,29 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-webmvc' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Spring Boot Starter + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Database + runtimeOnly 'org.postgresql:postgresql' + + // Lombok compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' } tasks.named('test') { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4bd15c5..304128b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,21 +3,30 @@ spring: name: vote datasource: - url: jdbc:mysql://localhost:3306/vote?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true - username: root - password: 로컬비번 !! - driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:vote} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 jpa: hibernate: - ddl-auto: create + ddl-auto: ${DDL_AUTO:update} properties: hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true - show_sql: true - dialect: org.hibernate.dialect.MySQLDialect + show-sql: true logging: level: org.hibernate.SQL: debug - org.hibernate.type.descriptor.sql: trace \ No newline at end of file + org.hibernate.type.descriptor.sql: trace + +jwt: + secret-key: ${JWT_SECRET_KEY} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} \ No newline at end of file From 47b8d5152e42005f735d8c7b18c7c24dd7cf82ec Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Wed, 17 Dec 2025 01:31:12 +0900 Subject: [PATCH 22/52] =?UTF-8?q?feat=20:=20=EA=B8=B0=EB=B3=B8=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/diggindie/vote/common/code/Code.java | 6 ++ .../diggindie/vote/common/code/ErrorCode.java | 19 +++++ .../vote/common/code/SuccessCode.java | 19 +++++ .../com/diggindie/vote/common/enums/Role.java | 13 ++++ .../vote/common/response/PageInfo.java | 9 +++ .../vote/common/response/Response.java | 71 +++++++++++++++++++ 6 files changed, 137 insertions(+) create mode 100644 src/main/java/com/diggindie/vote/common/code/Code.java create mode 100644 src/main/java/com/diggindie/vote/common/code/ErrorCode.java create mode 100644 src/main/java/com/diggindie/vote/common/code/SuccessCode.java create mode 100644 src/main/java/com/diggindie/vote/common/enums/Role.java create mode 100644 src/main/java/com/diggindie/vote/common/response/PageInfo.java create mode 100644 src/main/java/com/diggindie/vote/common/response/Response.java diff --git a/src/main/java/com/diggindie/vote/common/code/Code.java b/src/main/java/com/diggindie/vote/common/code/Code.java new file mode 100644 index 0000000..9ec06a6 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/Code.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.common.code; + +public interface Code { + int getStatusCode(); + String getMessage(); +} diff --git a/src/main/java/com/diggindie/vote/common/code/ErrorCode.java b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java new file mode 100644 index 0000000..33a6890 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java @@ -0,0 +1,19 @@ +package com.diggindie.vote.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements Code{ + + // 인증 실패 + UNAUTHORIZED_ERROR(401, "Unauthorized Exception"), + + // 권한 없음 + FORBIDDEN_ERROR(403, "Forbidden Exception"); + + private final int statusCode; + private final String message; + +} diff --git a/src/main/java/com/diggindie/vote/common/code/SuccessCode.java b/src/main/java/com/diggindie/vote/common/code/SuccessCode.java new file mode 100644 index 0000000..8d7868a --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/SuccessCode.java @@ -0,0 +1,19 @@ +package com.diggindie.vote.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessCode implements Code { + + GET_SUCCESS(200, "GET_SUCCESS"), + LOGIN_SUCCESS(200, "LOGIN_SUCCESS"), + DELETE_SUCCESS(200, "DELETE_SUCCESS"), + INSERT_SUCCESS(201, "INSERT_SUCCESS"), + UPDATE_SUCCESS(204, "UPDATE_SUCCESS"); + + private final int statusCode; + private final String message; + +} diff --git a/src/main/java/com/diggindie/vote/common/enums/Role.java b/src/main/java/com/diggindie/vote/common/enums/Role.java new file mode 100644 index 0000000..bc38c24 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/enums/Role.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.common.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + ROLE_USER("일반 사용자"), + ROLE_ADMIN("관리자"); + + private final String description; +} diff --git a/src/main/java/com/diggindie/vote/common/response/PageInfo.java b/src/main/java/com/diggindie/vote/common/response/PageInfo.java new file mode 100644 index 0000000..a90a8e5 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/response/PageInfo.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.common.response; + +public record PageInfo( + int page, + int size, + boolean hasNext, + long totalElements, + int totalPages +) {} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/response/Response.java b/src/main/java/com/diggindie/vote/common/response/Response.java new file mode 100644 index 0000000..bf00e47 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/response/Response.java @@ -0,0 +1,71 @@ +package com.diggindie.vote.common.response; + + +import com.diggindie.vote.common.code.Code; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Response { + + private int statusCode; + + @Getter(AccessLevel.NONE) + private boolean isSuccess; + + private String message; + + private PageInfo pageInfo; + + private T payload; + + @JsonProperty("isSuccess") + public boolean isSuccess() { + return isSuccess; + } + + // Non-paginated + public static Response of(Code code, boolean isSuccess, T payload) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(code.getMessage()) + .payload(payload) + .build(); + } + + public static Response of(Code code, boolean isSuccess, String message, T payload) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(message) + .payload(payload) + .build(); + } + + // Paginated + public static Response of(Code code, boolean isSuccess, T payload, PageInfo pageInfo) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(code.getMessage()) + .pageInfo(pageInfo) + .payload(payload) + .build(); + } + + public static Response of(Code code, boolean isSuccess, String message, T payload, PageInfo pageInfo) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(message) + .pageInfo(pageInfo) + .payload(payload) + .build(); + } +} From f3ad2530ef84ac989b260eabfb3873ed3369137d Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Wed, 17 Dec 2025 01:31:26 +0900 Subject: [PATCH 23/52] =?UTF-8?q?feat=20:=20security=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/CustomUserDetailService.java | 33 ++++ .../config/security/CustomUserDetails.java | 58 +++++++ .../config/security/SecurityConfig.java | 59 +++++++ .../security/jwt/JwtAccessDeniedHandler.java | 37 +++++ .../jwt/JwtAuthenticationEntryPoint.java | 38 +++++ .../security/jwt/JwtAuthenticationFilter.java | 37 +++++ .../config/security/jwt/JwtTokenProvider.java | 145 ++++++++++++++++++ .../vote/domain/member/domain/Member.java | 7 + .../member/repository/MemberRepository.java | 16 ++ 9 files changed, 430 insertions(+) create mode 100644 src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java new file mode 100644 index 0000000..c776ed0 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java @@ -0,0 +1,33 @@ +package com.diggindie.vote.common.config.security; + +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CustomUserDetailService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public CustomUserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { + + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("Member not found")); + + return new CustomUserDetails(member.getId(), member.getLoginId(), member.getRole()); + } + + + public CustomUserDetails loadByExternalId(String externalId) { + + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new UsernameNotFoundException("Member not found")); + + return new CustomUserDetails(member.getId(), member.getLoginId(), member.getRole()); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java new file mode 100644 index 0000000..6dfbba4 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java @@ -0,0 +1,58 @@ +package com.diggindie.vote.common.config.security; + +import com.diggindie.vote.common.enums.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Long memberId; + private final String userId; + private final Role role; + + public CustomUserDetails(Long memberId, String userId, Role role) { + this.memberId = memberId; + this.userId = userId; + this.role = role; + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(role.name())); + } + + @Override + public String getUsername() { + return userId; + } + + public Long getMemberId() { + return memberId; + } + + @Override public String getPassword() { + return null; + } + + @Override public boolean isAccountNonExpired() { + return true; + } + + @Override public boolean isAccountNonLocked() { + return true; + } + + @Override public boolean isCredentialsNonExpired() { + return true; + } + + @Override public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java new file mode 100644 index 0000000..de96f02 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java @@ -0,0 +1,59 @@ +package com.diggindie.vote.common.config.security; + + +import com.diggindie.vote.common.config.security.jwt.JwtAccessDeniedHandler; +import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationEntryPoint; +import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationFilter; +import com.diggindie.vote.common.config.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint authenticationEntryPoint; + private final JwtAccessDeniedHandler accessDeniedHandler; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + JwtTokenProvider jwtTokenProvider + + ) throws Exception { + + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + ) + .addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..b545cd4 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,37 @@ +package com.diggindie.vote.common.config.security.jwt; + +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.response.Response; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + Response body = Response.of(ErrorCode.FORBIDDEN_ERROR, false, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + response.getWriter().flush(); + } + +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..4fbfd2e --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.diggindie.vote.common.config.security.jwt; + + +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.response.Response; +import tools.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + Response body = Response.of(ErrorCode.UNAUTHORIZED_ERROR, false, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + response.getWriter().flush(); + + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3a0db64 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,37 @@ +package com.diggindie.vote.common.config.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = jwtTokenProvider.getAccessToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..4c00360 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java @@ -0,0 +1,145 @@ +package com.diggindie.vote.common.config.security.jwt; + + +import com.diggindie.vote.common.config.security.CustomUserDetailService; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.enums.Role; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.security.Key; +import java.time.Duration; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider implements InitializingBean { + + @Value("${jwt.secret-key}") + private String secretKey; + + @Value("${jwt.access-token-validity}") + private Duration accessTokenValidity; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + private Key key; + + private final CustomUserDetailService customUserDetailService; + + @Override + public void afterPropertiesSet() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String generateAccessToken(String externalId, Role role) { + return generateToken(externalId, role, accessTokenValidity); + } + + public String generateRefreshToken(String externalId, Role role) { + return generateToken(externalId, role, refreshTokenValidity); + } + + public String generateToken(String externalId, Role role, Duration expiration) { + + Date now = new Date(); + Date expiry = new Date(now.getTime() + expiration.toMillis()); + + return Jwts.builder() + .setSubject(externalId) + .claim("role", role.name()) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String getAccessToken(HttpServletRequest request) { + + // cookie 기반 토큰 추출 + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + + // header 기반 토큰 추출 + String bearerToken = request.getHeader("Authorization"); + + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + public Claims parseClaims(String token) { + + Claims claims = Jwts.parser() + .verifyWith((SecretKey)key) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims; + } + + public String getExternalId(String token) { + return parseClaims(token).getSubject(); + } + + public Role getRole(String token) { + String roleName = parseClaims(token).get("role", String.class); + return Role.valueOf(roleName); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); // 파싱과 동시에 검증 수행 + return true; + } catch (SecurityException | MalformedJwtException e) { + // 잘못된 서명 또는 JWT 형식 + log.warn("JWT 토큰 형식이 잘못되었습니다.", e); + } catch (ExpiredJwtException e) { + // 만료된 JWT + log.warn("만료된 JWT 토큰이 사용되었습니다.", e); + } catch (UnsupportedJwtException e) { + // 지원하지 않는 JWT + log.warn("지원하지 않는 JWT 토큰이 사용되었습니다.", e); + } catch (IllegalArgumentException e) { + // 빈 JWT 또는 기타 문제 + log.warn("JWT 토큰의 값이 비어있습니다.", e); + } + return false; + } + + public Authentication getAuthentication(String token) { + + String externalId = getExternalId(token); + Role role = getRole(token); + + CustomUserDetails userDetails = customUserDetailService.loadByExternalId(externalId); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } + +} diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java index 76634df..22c6d10 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -1,6 +1,7 @@ package com.diggindie.vote.domain.member.domain; import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.enums.Role; import com.diggindie.vote.domain.team.domain.Team; import jakarta.persistence.*; import lombok.*; @@ -16,6 +17,12 @@ public class Member { @Column(name = "member_id") private Long id; + @Column(name = "external_id", nullable = false, length = 36, unique = true) + private String externalId; + + @Enumerated(EnumType.STRING) + private Role role; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id", nullable = false) private Team team; diff --git a/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..31265fa --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java @@ -0,0 +1,16 @@ +package com.diggindie.vote.domain.member.repository; + +import com.diggindie.vote.domain.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + Optional findByExternalId(String externalId); + +} \ No newline at end of file From 4181fda69a1c54e8d193608d8a5d77cb3a88927b Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Wed, 17 Dec 2025 01:35:45 +0900 Subject: [PATCH 24/52] =?UTF-8?q?feat:=20PostgreSQL=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 7 ++++++- build.gradle | 2 +- src/main/resources/application.yml | 10 +++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e4f5090..6dfac53 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -59,4 +59,9 @@ jobs: docker pull ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest docker stop spring-vote || true docker rm spring-vote || true - docker run -d --name spring-vote -p 8080:8080 ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest + docker run -d --name spring-vote -p 8080:8080 \ + -e DB_HOST=${{ secrets.DB_HOST }} \ + -e DB_NAME=${{ secrets.DB_NAME }} \ + -e DB_USERNAME=${{ secrets.DB_USERNAME }} \ + -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \ + ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest \ No newline at end of file diff --git a/build.gradle b/build.gradle index f614035..fa6f155 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' - runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.postgresql:postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4bd15c5..515b2b1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,10 +3,10 @@ spring: name: vote datasource: - url: jdbc:mysql://localhost:3306/vote?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true - username: root - password: 로컬비번 !! - driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:postgresql://${DB_HOST:localhost}:5432/${DB_NAME:springvote} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:password} + driver-class-name: org.postgresql.Driver jpa: hibernate: @@ -15,7 +15,7 @@ spring: hibernate: format_sql: true show_sql: true - dialect: org.hibernate.dialect.MySQLDialect + dialect: org.hibernate.dialect.PostgreSQLDialect logging: level: From 86847c6ef86b4badea996827cb189bacc3e15185 Mon Sep 17 00:00:00 2001 From: Hoyoung Byun Date: Wed, 17 Dec 2025 01:50:24 +0900 Subject: [PATCH 25/52] =?UTF-8?q?[FEAT]=20=EA=B8=B0=EB=B3=B8=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 --------- Co-authored-by: Yoonji Lee --- .gitignore | 1 + build.gradle | 27 +++- .../com/diggindie/vote/common/code/Code.java | 6 + .../diggindie/vote/common/code/ErrorCode.java | 19 +++ .../vote/common/code/SuccessCode.java | 19 +++ .../security/CustomUserDetailService.java | 33 ++++ .../config/security/CustomUserDetails.java | 58 +++++++ .../config/security/SecurityConfig.java | 59 +++++++ .../security/jwt/JwtAccessDeniedHandler.java | 37 +++++ .../jwt/JwtAuthenticationEntryPoint.java | 38 +++++ .../security/jwt/JwtAuthenticationFilter.java | 37 +++++ .../config/security/jwt/JwtTokenProvider.java | 145 ++++++++++++++++++ .../com/diggindie/vote/common/enums/Role.java | 13 ++ .../vote/common/response/PageInfo.java | 9 ++ .../vote/common/response/Response.java | 71 +++++++++ .../vote/domain/member/domain/Member.java | 7 + .../member/repository/MemberRepository.java | 16 ++ src/main/resources/application.yml | 19 ++- 18 files changed, 604 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/diggindie/vote/common/code/Code.java create mode 100644 src/main/java/com/diggindie/vote/common/code/ErrorCode.java create mode 100644 src/main/java/com/diggindie/vote/common/code/SuccessCode.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/diggindie/vote/common/enums/Role.java create mode 100644 src/main/java/com/diggindie/vote/common/response/PageInfo.java create mode 100644 src/main/java/com/diggindie/vote/common/response/Response.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java diff --git a/.gitignore b/.gitignore index 983a5f7..d607a29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ HELP.md +.env .gradle build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/build.gradle b/build.gradle index fa6f155..ee29e1d 100644 --- a/build.gradle +++ b/build.gradle @@ -25,15 +25,32 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-webmvc' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Spring Boot Starter + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Database + runtimeOnly 'org.postgresql:postgresql' + + // Lombok compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' - implementation 'org.postgresql:postgresql' + implementation 'org.postgresql:postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' } tasks.named('test') { diff --git a/src/main/java/com/diggindie/vote/common/code/Code.java b/src/main/java/com/diggindie/vote/common/code/Code.java new file mode 100644 index 0000000..9ec06a6 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/Code.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.common.code; + +public interface Code { + int getStatusCode(); + String getMessage(); +} diff --git a/src/main/java/com/diggindie/vote/common/code/ErrorCode.java b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java new file mode 100644 index 0000000..33a6890 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java @@ -0,0 +1,19 @@ +package com.diggindie.vote.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements Code{ + + // 인증 실패 + UNAUTHORIZED_ERROR(401, "Unauthorized Exception"), + + // 권한 없음 + FORBIDDEN_ERROR(403, "Forbidden Exception"); + + private final int statusCode; + private final String message; + +} diff --git a/src/main/java/com/diggindie/vote/common/code/SuccessCode.java b/src/main/java/com/diggindie/vote/common/code/SuccessCode.java new file mode 100644 index 0000000..8d7868a --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/SuccessCode.java @@ -0,0 +1,19 @@ +package com.diggindie.vote.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessCode implements Code { + + GET_SUCCESS(200, "GET_SUCCESS"), + LOGIN_SUCCESS(200, "LOGIN_SUCCESS"), + DELETE_SUCCESS(200, "DELETE_SUCCESS"), + INSERT_SUCCESS(201, "INSERT_SUCCESS"), + UPDATE_SUCCESS(204, "UPDATE_SUCCESS"); + + private final int statusCode; + private final String message; + +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java new file mode 100644 index 0000000..c776ed0 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java @@ -0,0 +1,33 @@ +package com.diggindie.vote.common.config.security; + +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CustomUserDetailService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public CustomUserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { + + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("Member not found")); + + return new CustomUserDetails(member.getId(), member.getLoginId(), member.getRole()); + } + + + public CustomUserDetails loadByExternalId(String externalId) { + + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new UsernameNotFoundException("Member not found")); + + return new CustomUserDetails(member.getId(), member.getLoginId(), member.getRole()); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java new file mode 100644 index 0000000..6dfbba4 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java @@ -0,0 +1,58 @@ +package com.diggindie.vote.common.config.security; + +import com.diggindie.vote.common.enums.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Long memberId; + private final String userId; + private final Role role; + + public CustomUserDetails(Long memberId, String userId, Role role) { + this.memberId = memberId; + this.userId = userId; + this.role = role; + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(role.name())); + } + + @Override + public String getUsername() { + return userId; + } + + public Long getMemberId() { + return memberId; + } + + @Override public String getPassword() { + return null; + } + + @Override public boolean isAccountNonExpired() { + return true; + } + + @Override public boolean isAccountNonLocked() { + return true; + } + + @Override public boolean isCredentialsNonExpired() { + return true; + } + + @Override public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java new file mode 100644 index 0000000..de96f02 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java @@ -0,0 +1,59 @@ +package com.diggindie.vote.common.config.security; + + +import com.diggindie.vote.common.config.security.jwt.JwtAccessDeniedHandler; +import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationEntryPoint; +import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationFilter; +import com.diggindie.vote.common.config.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint authenticationEntryPoint; + private final JwtAccessDeniedHandler accessDeniedHandler; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + JwtTokenProvider jwtTokenProvider + + ) throws Exception { + + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + ) + .addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..b545cd4 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,37 @@ +package com.diggindie.vote.common.config.security.jwt; + +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.response.Response; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + Response body = Response.of(ErrorCode.FORBIDDEN_ERROR, false, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + response.getWriter().flush(); + } + +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..4fbfd2e --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.diggindie.vote.common.config.security.jwt; + + +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.response.Response; +import tools.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + Response body = Response.of(ErrorCode.UNAUTHORIZED_ERROR, false, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + response.getWriter().flush(); + + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3a0db64 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,37 @@ +package com.diggindie.vote.common.config.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = jwtTokenProvider.getAccessToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..4c00360 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java @@ -0,0 +1,145 @@ +package com.diggindie.vote.common.config.security.jwt; + + +import com.diggindie.vote.common.config.security.CustomUserDetailService; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.enums.Role; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.security.Key; +import java.time.Duration; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider implements InitializingBean { + + @Value("${jwt.secret-key}") + private String secretKey; + + @Value("${jwt.access-token-validity}") + private Duration accessTokenValidity; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + private Key key; + + private final CustomUserDetailService customUserDetailService; + + @Override + public void afterPropertiesSet() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String generateAccessToken(String externalId, Role role) { + return generateToken(externalId, role, accessTokenValidity); + } + + public String generateRefreshToken(String externalId, Role role) { + return generateToken(externalId, role, refreshTokenValidity); + } + + public String generateToken(String externalId, Role role, Duration expiration) { + + Date now = new Date(); + Date expiry = new Date(now.getTime() + expiration.toMillis()); + + return Jwts.builder() + .setSubject(externalId) + .claim("role", role.name()) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String getAccessToken(HttpServletRequest request) { + + // cookie 기반 토큰 추출 + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + + // header 기반 토큰 추출 + String bearerToken = request.getHeader("Authorization"); + + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + public Claims parseClaims(String token) { + + Claims claims = Jwts.parser() + .verifyWith((SecretKey)key) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims; + } + + public String getExternalId(String token) { + return parseClaims(token).getSubject(); + } + + public Role getRole(String token) { + String roleName = parseClaims(token).get("role", String.class); + return Role.valueOf(roleName); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); // 파싱과 동시에 검증 수행 + return true; + } catch (SecurityException | MalformedJwtException e) { + // 잘못된 서명 또는 JWT 형식 + log.warn("JWT 토큰 형식이 잘못되었습니다.", e); + } catch (ExpiredJwtException e) { + // 만료된 JWT + log.warn("만료된 JWT 토큰이 사용되었습니다.", e); + } catch (UnsupportedJwtException e) { + // 지원하지 않는 JWT + log.warn("지원하지 않는 JWT 토큰이 사용되었습니다.", e); + } catch (IllegalArgumentException e) { + // 빈 JWT 또는 기타 문제 + log.warn("JWT 토큰의 값이 비어있습니다.", e); + } + return false; + } + + public Authentication getAuthentication(String token) { + + String externalId = getExternalId(token); + Role role = getRole(token); + + CustomUserDetails userDetails = customUserDetailService.loadByExternalId(externalId); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } + +} diff --git a/src/main/java/com/diggindie/vote/common/enums/Role.java b/src/main/java/com/diggindie/vote/common/enums/Role.java new file mode 100644 index 0000000..bc38c24 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/enums/Role.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.common.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + ROLE_USER("일반 사용자"), + ROLE_ADMIN("관리자"); + + private final String description; +} diff --git a/src/main/java/com/diggindie/vote/common/response/PageInfo.java b/src/main/java/com/diggindie/vote/common/response/PageInfo.java new file mode 100644 index 0000000..a90a8e5 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/response/PageInfo.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.common.response; + +public record PageInfo( + int page, + int size, + boolean hasNext, + long totalElements, + int totalPages +) {} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/response/Response.java b/src/main/java/com/diggindie/vote/common/response/Response.java new file mode 100644 index 0000000..bf00e47 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/response/Response.java @@ -0,0 +1,71 @@ +package com.diggindie.vote.common.response; + + +import com.diggindie.vote.common.code.Code; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Response { + + private int statusCode; + + @Getter(AccessLevel.NONE) + private boolean isSuccess; + + private String message; + + private PageInfo pageInfo; + + private T payload; + + @JsonProperty("isSuccess") + public boolean isSuccess() { + return isSuccess; + } + + // Non-paginated + public static Response of(Code code, boolean isSuccess, T payload) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(code.getMessage()) + .payload(payload) + .build(); + } + + public static Response of(Code code, boolean isSuccess, String message, T payload) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(message) + .payload(payload) + .build(); + } + + // Paginated + public static Response of(Code code, boolean isSuccess, T payload, PageInfo pageInfo) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(code.getMessage()) + .pageInfo(pageInfo) + .payload(payload) + .build(); + } + + public static Response of(Code code, boolean isSuccess, String message, T payload, PageInfo pageInfo) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(message) + .pageInfo(pageInfo) + .payload(payload) + .build(); + } +} diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java index 76634df..22c6d10 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -1,6 +1,7 @@ package com.diggindie.vote.domain.member.domain; import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.enums.Role; import com.diggindie.vote.domain.team.domain.Team; import jakarta.persistence.*; import lombok.*; @@ -16,6 +17,12 @@ public class Member { @Column(name = "member_id") private Long id; + @Column(name = "external_id", nullable = false, length = 36, unique = true) + private String externalId; + + @Enumerated(EnumType.STRING) + private Role role; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id", nullable = false) private Team team; diff --git a/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..31265fa --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java @@ -0,0 +1,16 @@ +package com.diggindie.vote.domain.member.repository; + +import com.diggindie.vote.domain.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + Optional findByExternalId(String externalId); + +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 515b2b1..d2d2c86 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,21 +3,30 @@ spring: name: vote datasource: - url: jdbc:postgresql://${DB_HOST:localhost}:5432/${DB_NAME:springvote} + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:vote} username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:password} driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 jpa: hibernate: - ddl-auto: create + ddl-auto: ${DDL_AUTO:update} + show-sql: true properties: hibernate: - format_sql: true - show_sql: true dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true logging: level: org.hibernate.SQL: debug - org.hibernate.type.descriptor.sql: trace \ No newline at end of file + org.hibernate.type.descriptor.sql: trace + +jwt: + secret-key: ${JWT_SECRET_KEY} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} From 367927e6749059923be6e5d840215d86dc58e3d9 Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Wed, 17 Dec 2025 02:27:32 +0900 Subject: [PATCH 26/52] =?UTF-8?q?feat=20:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/AuthController.java | 72 ++++++++++ .../vote/domain/member/domain/Member.java | 15 ++ .../vote/domain/member/dto/LoginRequest.java | 7 + .../vote/domain/member/dto/LoginResponse.java | 13 ++ .../domain/member/dto/LogoutResponse.java | 6 + .../vote/domain/member/dto/SignupRequest.java | 13 ++ .../domain/member/dto/SignupResponse.java | 13 ++ .../domain/member/service/AuthService.java | 132 ++++++++++++++++++ .../team/repository/TeamRepository.java | 13 ++ 9 files changed, 284 insertions(+) create mode 100644 src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/service/AuthService.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java new file mode 100644 index 0000000..256be7d --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java @@ -0,0 +1,72 @@ +package com.diggindie.vote.domain.member.controller; + + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.member.dto.LoginRequest; +import com.diggindie.vote.domain.member.dto.LoginResponse; +import com.diggindie.vote.domain.member.dto.LogoutResponse; +import com.diggindie.vote.domain.member.dto.SignupRequest; +import com.diggindie.vote.domain.member.dto.SignupResponse; +import com.diggindie.vote.domain.member.service.AuthService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/auth/signup") + public ResponseEntity> signup( + @RequestBody SignupRequest signupRequest, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.INSERT_SUCCESS, + true, + "회원 가입 API", + authService.signup(signupRequest, httpResponse) + ); + return ResponseEntity.ok().body(response); + } + + @PostMapping("/auth/login") + public ResponseEntity> login( + @RequestBody LoginRequest loginRequest, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "일반 로그인 API", + authService.login(loginRequest, httpResponse) + ); + return ResponseEntity.ok().body(response); + } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/auth/logout") + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "로그아웃 API", + authService.logout(httpResponse, userDetails.getMemberId()) + ); + return ResponseEntity.ok().body(response); + } + +} diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java index 22c6d10..cc6db8a 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -6,6 +6,8 @@ import jakarta.persistence.*; import lombok.*; +import java.util.UUID; + @Entity @Table(name = "member") @Getter @@ -46,4 +48,17 @@ public class Member { @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private Candidate candidate; + @Builder + public Member(Role role, Team team, Part part, String loginId, String email, String password, String memberName) { + this.externalId = UUID.randomUUID().toString(); + this.role = role; + this.team = team; + this.part = part; + this.loginId = loginId; + this.email = email; + this.password = password; + this.memberName = memberName; + this.role = Role.ROLE_USER; + } + } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java b/src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java new file mode 100644 index 0000000..e7e40d9 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java @@ -0,0 +1,7 @@ +package com.diggindie.vote.domain.member.dto; + +public record LoginRequest( + String loginId, + String password +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java new file mode 100644 index 0000000..10f9741 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.diggindie.vote.common.enums.Part; + +public record LoginResponse( + String memberId, + String name, + Part part, + String team, + String accessToken, + long expiresIn +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java new file mode 100644 index 0000000..0382734 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.domain.member.dto; + +public record LogoutResponse( + String memberId +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java b/src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java new file mode 100644 index 0000000..5899460 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.diggindie.vote.common.enums.Part; + +public record SignupRequest( + String loginId, + String password, + String email, + Part part, + String name, + String team +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java new file mode 100644 index 0000000..2156f79 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.diggindie.vote.common.enums.Part; + +public record SignupResponse( + String memberId, + String name, + Part part, + String team, + String accessToken, + long expiresIn +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java new file mode 100644 index 0000000..5ce02bc --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java @@ -0,0 +1,132 @@ +package com.diggindie.vote.domain.member.service; + +import com.diggindie.vote.common.config.security.jwt.JwtTokenProvider; +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.enums.Role; +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.dto.*; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import com.diggindie.vote.domain.team.repository.TeamRepository; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepository; + private final TeamRepository teamRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${jwt.access-token-validity}") + private Duration accessTokenValidity; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + @Transactional(readOnly = true) + public LoginResponse login(LoginRequest request, HttpServletResponse response) { + + Member member = memberRepository.findByLoginId(request.loginId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 아이디입니다.")); + + if (!passwordEncoder.matches(request.password(), member.getPassword())) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + } + + String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); + setCookies(response, member.getExternalId(), member.getRole()); + + + return new LoginResponse( + member.getExternalId(), + member.getMemberName(), + member.getPart(), + member.getTeam().getTeamName(), + accessToken, + accessTokenValidity.getSeconds() + ); + } + + @Transactional + public SignupResponse signup(SignupRequest request, HttpServletResponse response) { + + String encodedPassword = passwordEncoder.encode(request.password()); + + Member member = Member.builder() + .loginId(request.loginId()) + .password(encodedPassword) + .email(request.email()) + .part(request.part()) + .memberName(request.name()) + .team(teamRepository.findByTeamName(request.team()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 팀입니다."))) + .build(); + + Member savedMember = memberRepository.save(member); + + String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); + setCookies(response, savedMember.getExternalId(), savedMember.getRole()); + + return new SignupResponse( + savedMember.getExternalId(), + savedMember.getMemberName(), + savedMember.getPart(), + savedMember.getTeam().getTeamName(), + accessToken, + accessTokenValidity.getSeconds() + ); + } + + public LogoutResponse logout(HttpServletResponse response, Long memberId) { + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + String externalId = member.getExternalId(); + removeRefreshTokenCookie(response); + + return new LogoutResponse(externalId); + } + + private void removeRefreshTokenCookie(HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .path("/") + .maxAge(0) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + private void setCookies(HttpServletResponse response, String externalId, Role role) { + + String refreshToken = jwtTokenProvider.generateRefreshToken(externalId, role); + addTokenCookie(response, "refreshToken", refreshToken, refreshTokenValidity); + + } + + private void addTokenCookie(HttpServletResponse response, String name, String value, Duration maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .path("/") + .maxAge(maxAge) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + +} diff --git a/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java new file mode 100644 index 0000000..c1a16cc --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.team.repository; + +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.team.domain.Team; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TeamRepository extends JpaRepository { + + Optional findByTeamName(String teamName); + +} From ca27b30901b3e84ccdf8937ee8031ae92decdccb Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Wed, 17 Dec 2025 03:00:59 +0900 Subject: [PATCH 27/52] =?UTF-8?q?feat=20:=20=ED=8C=80=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EB=B0=98=ED=99=98=20API=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/controller/TeamController.java | 32 +++++++++++++++++ .../vote/domain/team/dto/TeamDto.java | 9 +++++ .../domain/team/dto/TeamListResponse.java | 9 +++++ .../vote/domain/team/service/TeamService.java | 34 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/service/TeamService.java diff --git a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java new file mode 100644 index 0000000..841985f --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java @@ -0,0 +1,32 @@ +package com.diggindie.vote.domain.team.controller; + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.team.dto.TeamListResponse; +import com.diggindie.vote.domain.team.service.TeamService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class TeamController { + + private final TeamService teamService; + + @PreAuthorize("isAuthenticated()") + @GetMapping("/teams") + public ResponseEntity> getTeamList() { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "팀 목록 반환 API", + teamService.getTeamList() + ); + return ResponseEntity.ok().body(response); + } +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java new file mode 100644 index 0000000..c405a2c --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.team.dto; + +public record TeamDto( + Long teamId, + String teamName, + String teamProposal +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java new file mode 100644 index 0000000..94d6cad --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.team.dto; + +import java.util.List; + +public record TeamListResponse( + List teams +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java new file mode 100644 index 0000000..a70e131 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java @@ -0,0 +1,34 @@ +package com.diggindie.vote.domain.team.service; + +import com.diggindie.vote.domain.team.domain.Team; +import com.diggindie.vote.domain.team.dto.TeamDto; +import com.diggindie.vote.domain.team.dto.TeamListResponse; +import com.diggindie.vote.domain.team.repository.TeamRepository; +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 TeamService { + + private final TeamRepository teamRepository; + + public TeamListResponse getTeamList() { + List teams = teamRepository.findAll(); + + List teamDtos = teams.stream() + .map(team -> new TeamDto( + team.getId(), + team.getTeamName(), + team.getProposal() + )) + .toList(); + + return new TeamListResponse(teamDtos); + } +} + From b06eb9dd1eb2057b69aeea829fe1702510db7683 Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Wed, 17 Dec 2025 03:01:09 +0900 Subject: [PATCH 28/52] =?UTF-8?q?feat=20:=20=ED=8C=80=20=ED=86=A0=ED=91=9C?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=ED=99=98=20API=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vote/controller/TeamVoteController.java | 32 +++++++++++++ .../domain/vote/dto/TeamVoteResultDto.java | 10 ++++ .../vote/dto/TeamVoteResultResponse.java | 9 ++++ .../vote/repository/TeamVoteRepository.java | 14 ++++++ .../domain/vote/service/TeamVoteService.java | 46 +++++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 src/main/java/com/diggindie/vote/domain/vote/controller/TeamVoteController.java create mode 100644 src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/vote/repository/TeamVoteRepository.java create mode 100644 src/main/java/com/diggindie/vote/domain/vote/service/TeamVoteService.java diff --git a/src/main/java/com/diggindie/vote/domain/vote/controller/TeamVoteController.java b/src/main/java/com/diggindie/vote/domain/vote/controller/TeamVoteController.java new file mode 100644 index 0000000..90d9742 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/controller/TeamVoteController.java @@ -0,0 +1,32 @@ +package com.diggindie.vote.domain.vote.controller; + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.vote.dto.TeamVoteResultResponse; +import com.diggindie.vote.domain.vote.service.TeamVoteService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class TeamVoteController { + + private final TeamVoteService teamVoteService; + + @PreAuthorize("isAuthenticated()") + @GetMapping("/votes/teams") + public ResponseEntity> getTeamVoteResults() { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "팀 투표 결과 반환 API", + teamVoteService.getTeamVoteResults() + ); + return ResponseEntity.ok().body(response); + } +} + diff --git a/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultDto.java b/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultDto.java new file mode 100644 index 0000000..27d8b43 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultDto.java @@ -0,0 +1,10 @@ +package com.diggindie.vote.domain.vote.dto; + +public record TeamVoteResultDto( + Long teamId, + String teamName, + String teamProposal, + Long currentVote +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultResponse.java b/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultResponse.java new file mode 100644 index 0000000..ecbd9e8 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultResponse.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.vote.dto; + +import java.util.List; + +public record TeamVoteResultResponse( + List teams +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/vote/repository/TeamVoteRepository.java b/src/main/java/com/diggindie/vote/domain/vote/repository/TeamVoteRepository.java new file mode 100644 index 0000000..31942d7 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/repository/TeamVoteRepository.java @@ -0,0 +1,14 @@ +package com.diggindie.vote.domain.vote.repository; + +import com.diggindie.vote.domain.vote.domain.TeamVote; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface TeamVoteRepository extends JpaRepository { + + @Query("SELECT tv.team.id, COUNT(tv) FROM TeamVote tv GROUP BY tv.team.id") + List countVotesByTeam(); +} + diff --git a/src/main/java/com/diggindie/vote/domain/vote/service/TeamVoteService.java b/src/main/java/com/diggindie/vote/domain/vote/service/TeamVoteService.java new file mode 100644 index 0000000..303ad86 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/service/TeamVoteService.java @@ -0,0 +1,46 @@ +package com.diggindie.vote.domain.vote.service; + +import com.diggindie.vote.domain.team.domain.Team; +import com.diggindie.vote.domain.team.repository.TeamRepository; +import com.diggindie.vote.domain.vote.dto.TeamVoteResultDto; +import com.diggindie.vote.domain.vote.dto.TeamVoteResultResponse; +import com.diggindie.vote.domain.vote.repository.TeamVoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamVoteService { + + private final TeamVoteRepository teamVoteRepository; + private final TeamRepository teamRepository; + + public TeamVoteResultResponse getTeamVoteResults() { + List teams = teamRepository.findAll(); + List voteCounts = teamVoteRepository.countVotesByTeam(); + + Map voteCountMap = voteCounts.stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + + List teamVoteResults = teams.stream() + .map(team -> new TeamVoteResultDto( + team.getId(), + team.getTeamName(), + team.getProposal(), + voteCountMap.getOrDefault(team.getId(), 0L) + )) + .toList(); + + return new TeamVoteResultResponse(teamVoteResults); + } +} + From adfaf5907411200a301419342e9965e59a65479a Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Wed, 17 Dec 2025 17:57:49 +0900 Subject: [PATCH 29/52] =?UTF-8?q?feat=20:=20=ED=8C=8C=ED=8A=B8=EC=9E=A5=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20API=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CandidateController.java | 67 ++++++++++++++ .../member/dto/CandidateApplyResponse.java | 9 ++ .../vote/domain/member/dto/CandidateDto.java | 13 +++ .../member/dto/CandidateListResponse.java | 10 ++ .../repository/CandidateRepository.java | 18 ++++ .../member/service/CandidateService.java | 92 +++++++++++++++++++ .../vote/repository/PartVoteRepository.java | 16 ++++ 7 files changed, 225 insertions(+) create mode 100644 src/main/java/com/diggindie/vote/domain/member/controller/CandidateController.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/CandidateApplyResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/CandidateDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/CandidateListResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/repository/CandidateRepository.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java create mode 100644 src/main/java/com/diggindie/vote/domain/vote/repository/PartVoteRepository.java diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/CandidateController.java b/src/main/java/com/diggindie/vote/domain/member/controller/CandidateController.java new file mode 100644 index 0000000..c3c515a --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/controller/CandidateController.java @@ -0,0 +1,67 @@ +package com.diggindie.vote.domain.member.controller; + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.member.dto.CandidateApplyResponse; +import com.diggindie.vote.domain.member.dto.CandidateListResponse; +import com.diggindie.vote.domain.member.service.CandidateService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class CandidateController { + + private final CandidateService candidateService; + + @PreAuthorize("isAuthenticated()") + @GetMapping("/candidates") + public ResponseEntity> getCandidates( + @RequestParam("part") Part part + ) { + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "파트장 후보 반환 API", + candidateService.getCandidatesByPart(part) + ); + return ResponseEntity.ok().body(response); + } + + + @PreAuthorize("isAuthenticated()") + @GetMapping("/votes/leaders/results") + public ResponseEntity> getCandidatesVote( + @RequestParam("part") Part part + ) { + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "파트장 투표 결과 반환 API", + candidateService.getCandidateVoteByPart(part) + ); + return ResponseEntity.ok().body(response); + } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/candidates/apply") + public ResponseEntity> applyCandidate( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "파트장 후보 등록 API", + candidateService.applyCandidate(userDetails.getMemberId()) + ); + return ResponseEntity.ok().body(response); + } +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/CandidateApplyResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/CandidateApplyResponse.java new file mode 100644 index 0000000..99173d1 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/CandidateApplyResponse.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.member.dto; + +public record CandidateApplyResponse( + Long candidateId, + String candidateName, + String candidatePart +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/CandidateDto.java b/src/main/java/com/diggindie/vote/domain/member/dto/CandidateDto.java new file mode 100644 index 0000000..aa4d47c --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/CandidateDto.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record CandidateDto( + Long candidateId, + String candidateName, + String candidatePart, + Long currentVote +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/CandidateListResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/CandidateListResponse.java new file mode 100644 index 0000000..d5e13b8 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/CandidateListResponse.java @@ -0,0 +1,10 @@ +package com.diggindie.vote.domain.member.dto; + +import java.util.List; + +public record CandidateListResponse( + String part, + List candidates +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/member/repository/CandidateRepository.java b/src/main/java/com/diggindie/vote/domain/member/repository/CandidateRepository.java new file mode 100644 index 0000000..5649880 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/repository/CandidateRepository.java @@ -0,0 +1,18 @@ +package com.diggindie.vote.domain.member.repository; + +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.domain.member.domain.Candidate; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CandidateRepository extends JpaRepository { + + @Query("SELECT c FROM Candidate c JOIN FETCH c.member m WHERE m.part = :part") + List findAllByPart(@Param("part") Part part); +} + diff --git a/src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java b/src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java new file mode 100644 index 0000000..9a5f479 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java @@ -0,0 +1,92 @@ +package com.diggindie.vote.domain.member.service; + +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.domain.member.domain.Candidate; +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.dto.CandidateApplyResponse; +import com.diggindie.vote.domain.member.dto.CandidateDto; +import com.diggindie.vote.domain.member.dto.CandidateListResponse; +import com.diggindie.vote.domain.member.repository.CandidateRepository; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import com.diggindie.vote.domain.vote.repository.PartVoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CandidateService { + + private final CandidateRepository candidateRepository; + private final PartVoteRepository partVoteRepository; + private final MemberRepository memberRepository; + + public CandidateListResponse getCandidatesByPart(Part part) { + List candidates = candidateRepository.findAllByPart(part); + List voteCounts = partVoteRepository.countVotesByCandidate(); + + Map voteCountMap = voteCounts.stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + + List candidateDtos = candidates.stream() + .map(candidate -> new CandidateDto( + candidate.getId(), + candidate.getMember().getMemberName(), + candidate.getMember().getPart().toString(), + null + )) + .toList(); + + return new CandidateListResponse(part.toString(), candidateDtos); + } + + public CandidateListResponse getCandidateVoteByPart(Part part) { + List candidates = candidateRepository.findAllByPart(part); + List voteCounts = partVoteRepository.countVotesByCandidate(); + + Map voteCountMap = voteCounts.stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + + List candidateDtos = candidates.stream() + .map(candidate -> new CandidateDto( + candidate.getId(), + candidate.getMember().getMemberName(), + candidate.getMember().getPart().toString(), + voteCountMap.getOrDefault(candidate.getId(),0L) + )) + .toList(); + + return new CandidateListResponse(part.toString(), candidateDtos); + } + + @Transactional + public CandidateApplyResponse applyCandidate(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + // 이미 후보로 등록되어 있는지 확인 + if (member.getCandidate() != null) { + throw new IllegalArgumentException("이미 파트장 후보로 등록되어 있습니다."); + } + + Candidate candidate = new Candidate(member); + Candidate savedCandidate = candidateRepository.save(candidate); + + return new CandidateApplyResponse( + savedCandidate.getId(), + member.getMemberName(), + member.getPart().toString() + ); + } +} diff --git a/src/main/java/com/diggindie/vote/domain/vote/repository/PartVoteRepository.java b/src/main/java/com/diggindie/vote/domain/vote/repository/PartVoteRepository.java new file mode 100644 index 0000000..8dbfb83 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/vote/repository/PartVoteRepository.java @@ -0,0 +1,16 @@ +package com.diggindie.vote.domain.vote.repository; + +import com.diggindie.vote.domain.vote.domain.PartVote; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PartVoteRepository extends JpaRepository { + + @Query("SELECT pv.candidate.id, COUNT(pv) FROM PartVote pv GROUP BY pv.candidate.id") + List countVotesByCandidate(); +} + From 6d98f7adcfc494900bf4e0f465b7a97b6b9b0faa Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Wed, 17 Dec 2025 23:28:16 +0900 Subject: [PATCH 30/52] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/CustomUserDetailService.java | 4 ++-- .../config/security/CustomUserDetails.java | 10 ++++++---- .../member/controller/AuthController.java | 2 +- .../vote/domain/member/domain/Member.java | 1 - .../member/repository/MemberRepository.java | 2 ++ .../domain/member/service/AuthService.java | 18 ++++++++---------- 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java index c776ed0..6fc2666 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java @@ -19,7 +19,7 @@ public CustomUserDetails loadUserByUsername(String loginId) throws UsernameNotFo Member member = memberRepository.findByLoginId(loginId) .orElseThrow(() -> new UsernameNotFoundException("Member not found")); - return new CustomUserDetails(member.getId(), member.getLoginId(), member.getRole()); + return new CustomUserDetails(member.getId(), member.getExternalId(), member.getRole()); } @@ -28,6 +28,6 @@ public CustomUserDetails loadByExternalId(String externalId) { Member member = memberRepository.findByExternalId(externalId) .orElseThrow(() -> new UsernameNotFoundException("Member not found")); - return new CustomUserDetails(member.getId(), member.getLoginId(), member.getRole()); + return new CustomUserDetails(member.getId(), member.getExternalId(), member.getRole()); } } diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java index 6dfbba4..ebf1740 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java @@ -13,12 +13,12 @@ public class CustomUserDetails implements UserDetails { private final Long memberId; - private final String userId; + private final String externalId; private final Role role; - public CustomUserDetails(Long memberId, String userId, Role role) { + public CustomUserDetails(Long memberId, String externalId, Role role) { this.memberId = memberId; - this.userId = userId; + this.externalId = externalId; this.role = role; } @@ -29,9 +29,11 @@ public Collection getAuthorities() { @Override public String getUsername() { - return userId; + return externalId; } + public String getExternalId() { return externalId; } + public Long getMemberId() { return memberId; } diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java index 256be7d..86cbd99 100644 --- a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java +++ b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java @@ -64,7 +64,7 @@ public ResponseEntity> logout( SuccessCode.GET_SUCCESS, true, "로그아웃 API", - authService.logout(httpResponse, userDetails.getMemberId()) + authService.logout(httpResponse, userDetails.getExternalId()) ); return ResponseEntity.ok().body(response); } diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java index cc6db8a..62da8b0 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -51,7 +51,6 @@ public class Member { @Builder public Member(Role role, Team team, Part part, String loginId, String email, String password, String memberName) { this.externalId = UUID.randomUUID().toString(); - this.role = role; this.team = team; this.part = part; this.loginId = loginId; diff --git a/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java index 31265fa..1ac3b47 100644 --- a/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java @@ -13,4 +13,6 @@ public interface MemberRepository extends JpaRepository { Optional findByExternalId(String externalId); + boolean existsByLoginId(String loginId); + } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java index 5ce02bc..a05e065 100644 --- a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java +++ b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java @@ -36,10 +36,10 @@ public class AuthService { public LoginResponse login(LoginRequest request, HttpServletResponse response) { Member member = memberRepository.findByLoginId(request.loginId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 아이디입니다.")); + .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다.")); if (!passwordEncoder.matches(request.password(), member.getPassword())) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + throw new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다."); } String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); @@ -59,6 +59,10 @@ public LoginResponse login(LoginRequest request, HttpServletResponse response) { @Transactional public SignupResponse signup(SignupRequest request, HttpServletResponse response) { + if (memberRepository.existsByLoginId(request.loginId())) { + throw new IllegalArgumentException("이미 사용 중인 아이디입니다."); + } + String encodedPassword = passwordEncoder.encode(request.password()); Member member = Member.builder() @@ -86,14 +90,10 @@ public SignupResponse signup(SignupRequest request, HttpServletResponse response ); } - public LogoutResponse logout(HttpServletResponse response, Long memberId) { - - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + @Transactional(readOnly = true) + public LogoutResponse logout(HttpServletResponse response, String externalId) { - String externalId = member.getExternalId(); removeRefreshTokenCookie(response); - return new LogoutResponse(externalId); } @@ -110,10 +110,8 @@ private void removeRefreshTokenCookie(HttpServletResponse response) { } private void setCookies(HttpServletResponse response, String externalId, Role role) { - String refreshToken = jwtTokenProvider.generateRefreshToken(externalId, role); addTokenCookie(response, "refreshToken", refreshToken, refreshTokenValidity); - } private void addTokenCookie(HttpServletResponse response, String name, String value, Duration maxAge) { From 261b194c65d3ee6789b5af91843d45028534fba6 Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Wed, 17 Dec 2025 23:50:33 +0900 Subject: [PATCH 31/52] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vote/domain/member/service/CandidateService.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java b/src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java index 9a5f479..07d5206 100644 --- a/src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java +++ b/src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java @@ -28,13 +28,6 @@ public class CandidateService { public CandidateListResponse getCandidatesByPart(Part part) { List candidates = candidateRepository.findAllByPart(part); - List voteCounts = partVoteRepository.countVotesByCandidate(); - - Map voteCountMap = voteCounts.stream() - .collect(Collectors.toMap( - row -> (Long) row[0], - row -> (Long) row[1] - )); List candidateDtos = candidates.stream() .map(candidate -> new CandidateDto( From 7555d7f8c99c3307a6ae0411cf89e5e04e753a5f Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Thu, 18 Dec 2025 02:28:18 +0900 Subject: [PATCH 32/52] =?UTF-8?q?[FEAT]=20swagger=20=EC=84=B8=ED=8C=85=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?=ED=88=AC=ED=91=9C=20API=20=EA=B0=9C=EB=B0=9C,=20closes=20#14,?= =?UTF-8?q?=2016,=2018=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: swagger 관련 초기 세팅 * chore: candidate 패키지로 파일 이동 * refactor: 도메인 수정 * feat: 동시성 제어 * bug: externalId로 조회하도록 수정 * feat: 파트장 투표 관련 코드 추가 * feat: 팀 투표 API 개발 * fix: externalId 관련 수정 * fix: 락 기준 문제 수정 --- build.gradle | 11 +++- .../config/reddison/RedissonConfig.java | 28 ++++++++ .../common/config/swagger/SwaggerConfig.java | 32 +++++++++ .../controller/CandidateController.java | 48 ++++++++------ .../domain/Candidate.java | 14 +++- .../dto/CandidateApplyResponse.java | 2 +- .../dto/CandidateDto.java | 2 +- .../dto/CandidateListResponse.java | 2 +- .../candidate/dto/PartVoteRequestDto.java | 8 +++ .../repository/CandidateRepository.java | 4 +- .../service/CandidateService.java | 65 ++++++++++++------- .../candidate/service/PartVoteExecutor.java | 43 ++++++++++++ .../vote/domain/member/domain/Member.java | 18 ++++- .../team/controller/TeamController.java | 27 ++++++++ .../vote/domain/team/domain/Team.java | 11 ++++ .../domain/team/dto/TeamVoteRequestDto.java | 8 +++ .../vote/domain/team/service/TeamService.java | 31 +++++++++ .../domain/team/service/TeamVoteExecutor.java | 39 +++++++++++ .../vote/controller/TeamVoteController.java | 32 --------- .../vote/domain/vote/domain/PartVote.java | 28 -------- .../vote/domain/vote/domain/TeamVote.java | 29 --------- .../domain/vote/dto/TeamVoteResultDto.java | 10 --- .../vote/dto/TeamVoteResultResponse.java | 9 --- .../vote/repository/PartVoteRepository.java | 16 ----- .../vote/repository/TeamVoteRepository.java | 14 ---- .../domain/vote/service/TeamVoteService.java | 46 ------------- src/main/resources/application.yml | 11 +++- 27 files changed, 351 insertions(+), 237 deletions(-) create mode 100644 src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java create mode 100644 src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java rename src/main/java/com/diggindie/vote/domain/{member => candidate}/controller/CandidateController.java (61%) rename src/main/java/com/diggindie/vote/domain/{member => candidate}/domain/Candidate.java (56%) rename src/main/java/com/diggindie/vote/domain/{member => candidate}/dto/CandidateApplyResponse.java (72%) rename src/main/java/com/diggindie/vote/domain/{member => candidate}/dto/CandidateDto.java (83%) rename src/main/java/com/diggindie/vote/domain/{member => candidate}/dto/CandidateListResponse.java (72%) create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java rename src/main/java/com/diggindie/vote/domain/{member => candidate}/repository/CandidateRepository.java (81%) rename src/main/java/com/diggindie/vote/domain/{member => candidate}/service/CandidateService.java (55%) create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/controller/TeamVoteController.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultDto.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultResponse.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/repository/PartVoteRepository.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/repository/TeamVoteRepository.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/service/TeamVoteService.java diff --git a/build.gradle b/build.gradle index 34b29f9..d26e2fc 100644 --- a/build.gradle +++ b/build.gradle @@ -39,9 +39,10 @@ dependencies { // Lombok compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + implementation 'org.postgresql:postgresql' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + annotationProcessor 'org.projectlombok:lombok' // security implementation 'org.springframework.boot:spring-boot-starter-security' @@ -50,6 +51,12 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + + // reddison + implementation 'org.redisson:redisson:3.27.0' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0' } tasks.named('test') { diff --git a/src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java b/src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java new file mode 100644 index 0000000..b36c8b6 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java @@ -0,0 +1,28 @@ +package com.diggindie.vote.common.config.reddison; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://" + redisHost + ":" + redisPort) + .setConnectionMinimumIdleSize(1) + .setConnectionPoolSize(2); + return Redisson.create(config); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java new file mode 100644 index 0000000..67a7cfe --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java @@ -0,0 +1,32 @@ +package com.diggindie.vote.common.config.swagger; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "DiggIndie Vote API", + version = "v1", + description = "Voting service for CEOS 22nd" + ), + servers = { + @Server(url = "http://localhost:8080", description = "local") + // 배포 서버 있으면 추가 + // @Server(url = "https://api.yourdomain.com", description = "prod") + }, + security = @SecurityRequirement(name = "bearerAuth") +) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" +) +public class SwaggerConfig { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/CandidateController.java b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java similarity index 61% rename from src/main/java/com/diggindie/vote/domain/member/controller/CandidateController.java rename to src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java index c3c515a..7daf3f3 100644 --- a/src/main/java/com/diggindie/vote/domain/member/controller/CandidateController.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java @@ -1,21 +1,21 @@ -package com.diggindie.vote.domain.member.controller; +package com.diggindie.vote.domain.candidate.controller; import com.diggindie.vote.common.code.SuccessCode; import com.diggindie.vote.common.config.security.CustomUserDetails; import com.diggindie.vote.common.enums.Part; import com.diggindie.vote.common.response.Response; -import com.diggindie.vote.domain.member.dto.CandidateApplyResponse; -import com.diggindie.vote.domain.member.dto.CandidateListResponse; -import com.diggindie.vote.domain.member.service.CandidateService; +import com.diggindie.vote.domain.candidate.dto.CandidateApplyResponse; +import com.diggindie.vote.domain.candidate.dto.CandidateListResponse; +import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; +import com.diggindie.vote.domain.candidate.service.CandidateService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; 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.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +@Slf4j @RestController @RequiredArgsConstructor public class CandidateController { @@ -27,28 +27,25 @@ public class CandidateController { public ResponseEntity> getCandidates( @RequestParam("part") Part part ) { - Response response = Response.of( + return ResponseEntity.ok().body(Response.of( SuccessCode.GET_SUCCESS, true, "파트장 후보 반환 API", candidateService.getCandidatesByPart(part) - ); - return ResponseEntity.ok().body(response); + )); } - @PreAuthorize("isAuthenticated()") @GetMapping("/votes/leaders/results") public ResponseEntity> getCandidatesVote( @RequestParam("part") Part part ) { - Response response = Response.of( + return ResponseEntity.ok().body(Response.of( SuccessCode.GET_SUCCESS, true, "파트장 투표 결과 반환 API", candidateService.getCandidateVoteByPart(part) - ); - return ResponseEntity.ok().body(response); + )); } @PreAuthorize("isAuthenticated()") @@ -56,12 +53,25 @@ public ResponseEntity> getCandidatesVote( public ResponseEntity> applyCandidate( @AuthenticationPrincipal CustomUserDetails userDetails ) { - Response response = Response.of( + return ResponseEntity.ok().body(Response.of( SuccessCode.GET_SUCCESS, true, "파트장 후보 등록 API", candidateService.applyCandidate(userDetails.getMemberId()) - ); - return ResponseEntity.ok().body(response); + )); + } + + @PostMapping("/votes/leaders") + public ResponseEntity> voteCandidate( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody PartVoteRequestDto request + ) { + candidateService.vote(userDetails.getExternalId(), request); + return ResponseEntity.ok().body(Response.of( + SuccessCode.INSERT_SUCCESS, + true, + "파트장 투표 완료", + (Void) null + )); } -} +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java b/src/main/java/com/diggindie/vote/domain/candidate/domain/Candidate.java similarity index 56% rename from src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java rename to src/main/java/com/diggindie/vote/domain/candidate/domain/Candidate.java index 7f855f0..bcb0150 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/domain/Candidate.java @@ -1,5 +1,6 @@ -package com.diggindie.vote.domain.member.domain; +package com.diggindie.vote.domain.candidate.domain; +import com.diggindie.vote.domain.member.domain.Member; import jakarta.persistence.*; import lombok.*; @@ -18,8 +19,19 @@ public class Candidate { @JoinColumn(name = "member_id", nullable = false, unique = true) private Member member; + @Column(name = "vote_count", nullable = false) + private Integer voteCount = 0; + public Candidate(Member member) { this.member = member; + this.voteCount = 0; + } + + public void increaseVoteCount() { + this.voteCount++; } + public void decreaseVoteCount() { + if (this.voteCount > 0) this.voteCount--; + } } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/CandidateApplyResponse.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java similarity index 72% rename from src/main/java/com/diggindie/vote/domain/member/dto/CandidateApplyResponse.java rename to src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java index 99173d1..116d0f2 100644 --- a/src/main/java/com/diggindie/vote/domain/member/dto/CandidateApplyResponse.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java @@ -1,4 +1,4 @@ -package com.diggindie.vote.domain.member.dto; +package com.diggindie.vote.domain.candidate.dto; public record CandidateApplyResponse( Long candidateId, diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/CandidateDto.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java similarity index 83% rename from src/main/java/com/diggindie/vote/domain/member/dto/CandidateDto.java rename to src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java index aa4d47c..20b9c5f 100644 --- a/src/main/java/com/diggindie/vote/domain/member/dto/CandidateDto.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java @@ -1,4 +1,4 @@ -package com.diggindie.vote.domain.member.dto; +package com.diggindie.vote.domain.candidate.dto; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/CandidateListResponse.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java similarity index 72% rename from src/main/java/com/diggindie/vote/domain/member/dto/CandidateListResponse.java rename to src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java index d5e13b8..8c3b9cf 100644 --- a/src/main/java/com/diggindie/vote/domain/member/dto/CandidateListResponse.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java @@ -1,4 +1,4 @@ -package com.diggindie.vote.domain.member.dto; +package com.diggindie.vote.domain.candidate.dto; import java.util.List; diff --git a/src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java new file mode 100644 index 0000000..aba126a --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.candidate.dto; + +import jakarta.validation.constraints.NotNull; + +public record PartVoteRequestDto( + @NotNull(message = "후보자 ID는 필수입니다") + Long candidateId +) {} diff --git a/src/main/java/com/diggindie/vote/domain/member/repository/CandidateRepository.java b/src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java similarity index 81% rename from src/main/java/com/diggindie/vote/domain/member/repository/CandidateRepository.java rename to src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java index 5649880..fd4d381 100644 --- a/src/main/java/com/diggindie/vote/domain/member/repository/CandidateRepository.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java @@ -1,7 +1,7 @@ -package com.diggindie.vote.domain.member.repository; +package com.diggindie.vote.domain.candidate.repository; import com.diggindie.vote.common.enums.Part; -import com.diggindie.vote.domain.member.domain.Candidate; +import com.diggindie.vote.domain.candidate.domain.Candidate; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; diff --git a/src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java b/src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java similarity index 55% rename from src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java rename to src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java index 07d5206..6f6aac2 100644 --- a/src/main/java/com/diggindie/vote/domain/member/service/CandidateService.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java @@ -1,30 +1,36 @@ -package com.diggindie.vote.domain.member.service; +package com.diggindie.vote.domain.candidate.service; import com.diggindie.vote.common.enums.Part; -import com.diggindie.vote.domain.member.domain.Candidate; +import com.diggindie.vote.domain.candidate.domain.Candidate; +import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; import com.diggindie.vote.domain.member.domain.Member; -import com.diggindie.vote.domain.member.dto.CandidateApplyResponse; -import com.diggindie.vote.domain.member.dto.CandidateDto; -import com.diggindie.vote.domain.member.dto.CandidateListResponse; -import com.diggindie.vote.domain.member.repository.CandidateRepository; +import com.diggindie.vote.domain.candidate.dto.CandidateApplyResponse; +import com.diggindie.vote.domain.candidate.dto.CandidateDto; +import com.diggindie.vote.domain.candidate.dto.CandidateListResponse; +import com.diggindie.vote.domain.candidate.repository.CandidateRepository; import com.diggindie.vote.domain.member.repository.MemberRepository; -import com.diggindie.vote.domain.vote.repository.PartVoteRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; +import java.util.concurrent.TimeUnit; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class CandidateService { private final CandidateRepository candidateRepository; - private final PartVoteRepository partVoteRepository; private final MemberRepository memberRepository; + private final RedissonClient redissonClient; + private final PartVoteExecutor partVoteExecutor; + + private static final String PART_VOTE_LOCK_PREFIX = "vote:part:lock:"; public CandidateListResponse getCandidatesByPart(Part part) { List candidates = candidateRepository.findAllByPart(part); @@ -34,7 +40,7 @@ public CandidateListResponse getCandidatesByPart(Part part) { candidate.getId(), candidate.getMember().getMemberName(), candidate.getMember().getPart().toString(), - null + null // 투표 전에는 득표수 숨김 )) .toList(); @@ -43,32 +49,24 @@ public CandidateListResponse getCandidatesByPart(Part part) { public CandidateListResponse getCandidateVoteByPart(Part part) { List candidates = candidateRepository.findAllByPart(part); - List voteCounts = partVoteRepository.countVotesByCandidate(); - - Map voteCountMap = voteCounts.stream() - .collect(Collectors.toMap( - row -> (Long) row[0], - row -> (Long) row[1] - )); List candidateDtos = candidates.stream() .map(candidate -> new CandidateDto( candidate.getId(), candidate.getMember().getMemberName(), candidate.getMember().getPart().toString(), - voteCountMap.getOrDefault(candidate.getId(),0L) + (long) candidate.getVoteCount() // 엔티티에서 직접 조회 )) .toList(); return new CandidateListResponse(part.toString(), candidateDtos); } - @Transactional + @Transactional(readOnly = false) public CandidateApplyResponse applyCandidate(Long memberId) { Member member = memberRepository.findById(memberId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - // 이미 후보로 등록되어 있는지 확인 if (member.getCandidate() != null) { throw new IllegalArgumentException("이미 파트장 후보로 등록되어 있습니다."); } @@ -76,10 +74,33 @@ public CandidateApplyResponse applyCandidate(Long memberId) { Candidate candidate = new Candidate(member); Candidate savedCandidate = candidateRepository.save(candidate); + candidateRepository.flush(); + return new CandidateApplyResponse( savedCandidate.getId(), member.getMemberName(), member.getPart().toString() ); } -} + + public void vote(String externalId, PartVoteRequestDto request) { + String lockKey = PART_VOTE_LOCK_PREFIX + request.candidateId(); + RLock lock = redissonClient.getLock(lockKey); + + try { + boolean acquired = lock.tryLock(3, 15, TimeUnit.SECONDS); + if (!acquired) { + throw new IllegalStateException("요청이 많습니다. 잠시 후 다시 시도해주세요."); + } + + partVoteExecutor.execute(externalId, request); + log.info("파트장 투표 완료 - externalId: {}, candidateId: {}", externalId, request.candidateId()); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("투표 처리 중 오류가 발생했습니다."); + } finally { + if (lock.isHeldByCurrentThread()) lock.unlock(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java b/src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java new file mode 100644 index 0000000..3a65d65 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java @@ -0,0 +1,43 @@ +package com.diggindie.vote.domain.candidate.service; + +import com.diggindie.vote.domain.candidate.domain.Candidate; +import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; +import com.diggindie.vote.domain.candidate.repository.CandidateRepository; +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PartVoteExecutor { + + private final MemberRepository memberRepository; + private final CandidateRepository candidateRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void execute(String externalId, PartVoteRequestDto request) { + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.isHasVotedCandidate()) { + throw new IllegalStateException("이미 파트장 투표를 완료하였습니다."); + } + + Candidate candidate = candidateRepository.findById(request.candidateId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 후보자입니다.")); + + if (candidate.getMember().getId().equals(member.getId())) { + throw new IllegalStateException("자기 자신에게는 투표할 수 없습니다."); + } + + if (member.getPart() != candidate.getMember().getPart()) { + throw new IllegalStateException("같은 파트의 후보자에게만 투표할 수 있습니다."); + } + + candidate.increaseVoteCount(); + member.markCandidateVoted(); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java index 62da8b0..29f581a 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -2,6 +2,7 @@ import com.diggindie.vote.common.enums.Part; import com.diggindie.vote.common.enums.Role; +import com.diggindie.vote.domain.candidate.domain.Candidate; import com.diggindie.vote.domain.team.domain.Team; import jakarta.persistence.*; import lombok.*; @@ -48,16 +49,31 @@ public class Member { @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private Candidate candidate; + @Column(name = "has_voted_team", nullable = false) + private boolean hasVotedTeam = false; + + @Column(name = "has_voted_candidate", nullable = false) + private boolean hasVotedCandidate = false; + @Builder public Member(Role role, Team team, Part part, String loginId, String email, String password, String memberName) { this.externalId = UUID.randomUUID().toString(); + this.role = Role.ROLE_USER; this.team = team; this.part = part; this.loginId = loginId; this.email = email; this.password = password; this.memberName = memberName; - this.role = Role.ROLE_USER; + this.hasVotedTeam = false; + this.hasVotedCandidate = false; } + public void markTeamVoted() { + this.hasVotedTeam = true; + } + + public void markCandidateVoted() { + this.hasVotedCandidate = true; + } } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java index 841985f..bf9fbfa 100644 --- a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java +++ b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java @@ -1,16 +1,28 @@ package com.diggindie.vote.domain.team.controller; import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.config.security.CustomUserDetails; import com.diggindie.vote.common.response.Response; import com.diggindie.vote.domain.team.dto.TeamListResponse; +import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; import com.diggindie.vote.domain.team.service.TeamService; +import com.diggindie.vote.domain.team.service.TeamVoteExecutor; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +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.RestController; +import java.util.concurrent.TimeUnit; + @RestController +@Slf4j @RequiredArgsConstructor public class TeamController { @@ -28,5 +40,20 @@ public ResponseEntity> getTeamList() { ); return ResponseEntity.ok().body(response); } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/votes/teams") + public ResponseEntity> voteTeam( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody TeamVoteRequestDto request + ) { + teamService.vote(userDetails.getExternalId(), request); // getUserId() → getExternalId() + return ResponseEntity.ok().body(Response.of( + SuccessCode.INSERT_SUCCESS, + true, + "팀 투표 완료", + (Void) null + )); + } } diff --git a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java index 5c4afba..456c181 100644 --- a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java +++ b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java @@ -23,6 +23,17 @@ public class Team { @Column(name = "proposal", length = 200) private String proposal; + @Column(name = "vote_count", nullable = false) + private Integer voteCount = 0; + @OneToMany(mappedBy = "team") private List members = new ArrayList<>(); + + public void increaseVoteCount() { + this.voteCount++; + } + + public void decreaseVoteCount() { + if (this.voteCount > 0) this.voteCount--; + } } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java new file mode 100644 index 0000000..ce2fe8a --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.team.dto; + +import jakarta.validation.constraints.NotNull; + +public record TeamVoteRequestDto( + @NotNull(message = "팀 ID는 필수입니다") + Long teamId +) {} diff --git a/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java index a70e131..f8d92d8 100644 --- a/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java +++ b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java @@ -3,19 +3,29 @@ import com.diggindie.vote.domain.team.domain.Team; import com.diggindie.vote.domain.team.dto.TeamDto; import com.diggindie.vote.domain.team.dto.TeamListResponse; +import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; import com.diggindie.vote.domain.team.repository.TeamRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor +@Slf4j @Transactional(readOnly = true) public class TeamService { private final TeamRepository teamRepository; + private final RedissonClient redissonClient; + private final TeamVoteExecutor teamVoteExecutor; + + private static final String TEAM_VOTE_LOCK_PREFIX = "vote:team:lock:"; public TeamListResponse getTeamList() { List teams = teamRepository.findAll(); @@ -30,5 +40,26 @@ public TeamListResponse getTeamList() { return new TeamListResponse(teamDtos); } + + public void vote(String externalId, TeamVoteRequestDto request) { + String lockKey = TEAM_VOTE_LOCK_PREFIX + request.teamId(); + RLock lock = redissonClient.getLock(lockKey); + + try { + boolean acquired = lock.tryLock(3, 15, TimeUnit.SECONDS); + if (!acquired) { + throw new IllegalStateException("요청이 많습니다. 잠시 후 다시 시도해주세요."); + } + + teamVoteExecutor.execute(externalId, request); + log.info("팀 투표 완료 - externalId: {}, teamId: {}", externalId, request.teamId()); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("투표 처리 중 오류가 발생했습니다."); + } finally { + if (lock.isHeldByCurrentThread()) lock.unlock(); + } + } } diff --git a/src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java b/src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java new file mode 100644 index 0000000..55ddd44 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java @@ -0,0 +1,39 @@ +package com.diggindie.vote.domain.team.service; + +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import com.diggindie.vote.domain.team.domain.Team; +import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TeamVoteExecutor { + + private final MemberRepository memberRepository; + private final TeamRepository teamRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void execute(String externalId, TeamVoteRequestDto request) { + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.isHasVotedTeam()) { + throw new IllegalStateException("이미 팀 투표를 완료하셨습니다."); + } + + Team team = teamRepository.findById(request.teamId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 팀입니다.")); + + if (member.getTeam().getId().equals(team.getId())) { + throw new IllegalStateException("자신이 소속한 팀에는 투표할 수 없습니다."); + } + + team.increaseVoteCount(); + member.markTeamVoted(); + } +} diff --git a/src/main/java/com/diggindie/vote/domain/vote/controller/TeamVoteController.java b/src/main/java/com/diggindie/vote/domain/vote/controller/TeamVoteController.java deleted file mode 100644 index 90d9742..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/controller/TeamVoteController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.diggindie.vote.domain.vote.controller; - -import com.diggindie.vote.common.code.SuccessCode; -import com.diggindie.vote.common.response.Response; -import com.diggindie.vote.domain.vote.dto.TeamVoteResultResponse; -import com.diggindie.vote.domain.vote.service.TeamVoteService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class TeamVoteController { - - private final TeamVoteService teamVoteService; - - @PreAuthorize("isAuthenticated()") - @GetMapping("/votes/teams") - public ResponseEntity> getTeamVoteResults() { - - Response response = Response.of( - SuccessCode.GET_SUCCESS, - true, - "팀 투표 결과 반환 API", - teamVoteService.getTeamVoteResults() - ); - return ResponseEntity.ok().body(response); - } -} - diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java deleted file mode 100644 index e71574c..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.diggindie.vote.domain.vote.domain; - -import com.diggindie.vote.domain.member.domain.Candidate; -import com.diggindie.vote.domain.member.domain.Member; -import jakarta.persistence.*; -import lombok.*; - -@Entity -@Table(name = "part_vote", indexes = { - @Index(name = "idx_part_vote_candidate", columnList = "candidate_id") -}) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PartVote { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "part_vote_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "voter_member_id", nullable = false, unique = true) - private Member voter; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "candidate_id", nullable = false) - private Candidate candidate; -} diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java deleted file mode 100644 index 6d94a1d..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.diggindie.vote.domain.vote.domain; - -import com.diggindie.vote.domain.team.domain.Team; -import com.diggindie.vote.domain.member.domain.Member; -import jakarta.persistence.*; -import lombok.*; - -@Entity -@Table(name = "team_vote", indexes = { - @Index(name = "idx_team_vote_team", columnList = "team_id") -}) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class TeamVote { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "team_vote_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "voter_member_id", nullable = false, unique = true) - private Member voter; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "team_id", nullable = false) - private Team team; -} - diff --git a/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultDto.java b/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultDto.java deleted file mode 100644 index 27d8b43..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultDto.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.diggindie.vote.domain.vote.dto; - -public record TeamVoteResultDto( - Long teamId, - String teamName, - String teamProposal, - Long currentVote -) { -} - diff --git a/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultResponse.java b/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultResponse.java deleted file mode 100644 index ecbd9e8..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/dto/TeamVoteResultResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.diggindie.vote.domain.vote.dto; - -import java.util.List; - -public record TeamVoteResultResponse( - List teams -) { -} - diff --git a/src/main/java/com/diggindie/vote/domain/vote/repository/PartVoteRepository.java b/src/main/java/com/diggindie/vote/domain/vote/repository/PartVoteRepository.java deleted file mode 100644 index 8dbfb83..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/repository/PartVoteRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.diggindie.vote.domain.vote.repository; - -import com.diggindie.vote.domain.vote.domain.PartVote; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface PartVoteRepository extends JpaRepository { - - @Query("SELECT pv.candidate.id, COUNT(pv) FROM PartVote pv GROUP BY pv.candidate.id") - List countVotesByCandidate(); -} - diff --git a/src/main/java/com/diggindie/vote/domain/vote/repository/TeamVoteRepository.java b/src/main/java/com/diggindie/vote/domain/vote/repository/TeamVoteRepository.java deleted file mode 100644 index 31942d7..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/repository/TeamVoteRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.diggindie.vote.domain.vote.repository; - -import com.diggindie.vote.domain.vote.domain.TeamVote; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -import java.util.List; - -public interface TeamVoteRepository extends JpaRepository { - - @Query("SELECT tv.team.id, COUNT(tv) FROM TeamVote tv GROUP BY tv.team.id") - List countVotesByTeam(); -} - diff --git a/src/main/java/com/diggindie/vote/domain/vote/service/TeamVoteService.java b/src/main/java/com/diggindie/vote/domain/vote/service/TeamVoteService.java deleted file mode 100644 index 303ad86..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/service/TeamVoteService.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.diggindie.vote.domain.vote.service; - -import com.diggindie.vote.domain.team.domain.Team; -import com.diggindie.vote.domain.team.repository.TeamRepository; -import com.diggindie.vote.domain.vote.dto.TeamVoteResultDto; -import com.diggindie.vote.domain.vote.dto.TeamVoteResultResponse; -import com.diggindie.vote.domain.vote.repository.TeamVoteRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class TeamVoteService { - - private final TeamVoteRepository teamVoteRepository; - private final TeamRepository teamRepository; - - public TeamVoteResultResponse getTeamVoteResults() { - List teams = teamRepository.findAll(); - List voteCounts = teamVoteRepository.countVotesByTeam(); - - Map voteCountMap = voteCounts.stream() - .collect(Collectors.toMap( - row -> (Long) row[0], - row -> (Long) row[1] - )); - - List teamVoteResults = teams.stream() - .map(team -> new TeamVoteResultDto( - team.getId(), - team.getTeamName(), - team.getProposal(), - voteCountMap.getOrDefault(team.getId(), 0L) - )) - .toList(); - - return new TeamVoteResultResponse(teamVoteResults); - } -} - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d2d2c86..dcdeec7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,12 +21,17 @@ spring: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + logging: level: org.hibernate.SQL: debug org.hibernate.type.descriptor.sql: trace jwt: - secret-key: ${JWT_SECRET_KEY} - access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} - refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} + secret-key: ${JWT_SECRET_KEY:diggindievotingserviceeeeeeeeeeeee1234567890} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} \ No newline at end of file From 51888d05ec1b8f1086cd2ff9997babacd4a70180 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Thu, 18 Dec 2025 03:04:52 +0900 Subject: [PATCH 33/52] =?UTF-8?q?feat:=20docker-compose=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=B0=B0=ED=8F=AC=EC=8B=9C=20redis=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 43 ++++++++++++++++++++++++++++-------- docker-compose.yml | 22 ++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 docker-compose.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6dfac53..9ebc07c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -55,13 +55,38 @@ jobs: username: ec2-user key: ${{ secrets.EC2_SSH_KEY }} script: | + mkdir -p /home/ec2-user/app + cd /home/ec2-user/app + + cat > docker-compose.yml << 'EOF' + version: '3.8' + services: + app: + image: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest + ports: + - "8080:8080" + environment: + - DB_HOST=${{ secrets.DB_HOST }} + - DB_PORT=5432 + - DB_NAME=${{ secrets.DB_NAME }} + - DB_USERNAME=${{ secrets.DB_USERNAME }} + - DB_PASSWORD=${{ secrets.DB_PASSWORD }} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:alpine + ports: + - "6379:6379" + restart: unless-stopped + EOF + aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com - docker pull ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest - docker stop spring-vote || true - docker rm spring-vote || true - docker run -d --name spring-vote -p 8080:8080 \ - -e DB_HOST=${{ secrets.DB_HOST }} \ - -e DB_NAME=${{ secrets.DB_NAME }} \ - -e DB_USERNAME=${{ secrets.DB_USERNAME }} \ - -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \ - ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest \ No newline at end of file + + docker-compose down || true + docker-compose pull + docker-compose up -d \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..61502cf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' +services: + app: + image: ${ECR_IMAGE} + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=prod + - SPRING_DATASOURCE_URL=jdbc:postgresql://${DB_HOST}:5432/${DB_NAME} + - SPRING_DATASOURCE_USERNAME=${DB_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} + - SPRING_DATA_REDIS_HOST=redis + - SPRING_DATA_REDIS_PORT=6379 + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:alpine + ports: + - "6379:6379" + restart: unless-stopped \ No newline at end of file From 244867df5856c5c9e1d6f5801ad64bd696429e6c Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Thu, 18 Dec 2025 03:12:21 +0900 Subject: [PATCH 34/52] =?UTF-8?q?fix:=20=EB=A1=9C=EC=BB=AC=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=EC=9A=A9=20docker-compose=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 61502cf..0c4a85a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,21 @@ version: '3.8' services: app: - image: ${ECR_IMAGE} + build: . ports: - "8080:8080" environment: - - SPRING_PROFILES_ACTIVE=prod - - SPRING_DATASOURCE_URL=jdbc:postgresql://${DB_HOST}:5432/${DB_NAME} - - SPRING_DATASOURCE_USERNAME=${DB_USERNAME} - - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} - - SPRING_DATA_REDIS_HOST=redis - - SPRING_DATA_REDIS_PORT=6379 + - DB_HOST=host.docker.internal # ← 로컬 DB + - DB_PORT=5432 + - DB_NAME=vote + - DB_USERNAME=postgres + - DB_PASSWORD=password + - REDIS_HOST=redis + - REDIS_PORT=6379 depends_on: - redis - restart: unless-stopped redis: image: redis:alpine ports: - - "6379:6379" - restart: unless-stopped \ No newline at end of file + - "6379:6379" \ No newline at end of file From db916a4f5a94382cc6b6caa3b4583be2b53ec3d8 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Thu, 18 Dec 2025 03:20:57 +0900 Subject: [PATCH 35/52] =?UTF-8?q?chore:=20=EB=A1=9C=EC=BB=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=8F=84=EC=BB=A4=20=EC=BB=B4=ED=8F=AC=EC=A6=88=20env=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0c4a85a..e7b68fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,13 +5,14 @@ services: ports: - "8080:8080" environment: - - DB_HOST=host.docker.internal # ← 로컬 DB - - DB_PORT=5432 - - DB_NAME=vote - - DB_USERNAME=postgres - - DB_PASSWORD=password + - DB_HOST=${DB_HOST} + - DB_PORT=${DB_PORT} + - DB_NAME=${DB_NAME} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} - REDIS_HOST=redis - REDIS_PORT=6379 + - JWT_SECRET_KEY=${JWT_SECRET_KEY} depends_on: - redis From 9ba5f43a6bd7f0700fa30647b3ec17e8971e180c Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Thu, 18 Dec 2025 15:05:24 +0900 Subject: [PATCH 36/52] =?UTF-8?q?[DEPLOY]=20=EB=B0=B0=ED=8F=AC=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 * [FEAT] 기본 세팅 (#8) * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 --------- Co-authored-by: Yoonji Lee * feat : 로그인 관련 API 구현 * feat : 팀 목록 반환 API 개발 * feat : 팀 토표 결과 반환 API 개발 * feat : 파트장 관련 API 개발 * fix : 코드 리뷰 반영 * fix : 코드 리뷰 반영 * [FEAT] swagger 세팅 및 동시성 제어 투표 API 개발, closes #14, 16, 18 (#19) * feat: swagger 관련 초기 세팅 * chore: candidate 패키지로 파일 이동 * refactor: 도메인 수정 * feat: 동시성 제어 * bug: externalId로 조회하도록 수정 * feat: 파트장 투표 관련 코드 추가 * feat: 팀 투표 API 개발 * fix: externalId 관련 수정 * fix: 락 기준 문제 수정 * feat: docker-compose 추가 및 배포시 redis 설정 추가 * fix: 로컬 개발용 docker-compose 수정 * chore: 로컬용 도커 컴포즈 env 파일 사용하도록 수정 --------- Co-authored-by: Hoyoung027 --- .github/workflows/deploy.yml | 43 ++++-- .gitignore | 1 + build.gradle | 31 +++- docker-compose.yml | 22 +++ .../com/diggindie/vote/common/code/Code.java | 6 + .../diggindie/vote/common/code/ErrorCode.java | 19 +++ .../vote/common/code/SuccessCode.java | 19 +++ .../config/reddison/RedissonConfig.java | 28 ++++ .../security/CustomUserDetailService.java | 33 ++++ .../config/security/CustomUserDetails.java | 60 ++++++++ .../config/security/SecurityConfig.java | 59 +++++++ .../security/jwt/JwtAccessDeniedHandler.java | 37 +++++ .../jwt/JwtAuthenticationEntryPoint.java | 38 +++++ .../security/jwt/JwtAuthenticationFilter.java | 37 +++++ .../config/security/jwt/JwtTokenProvider.java | 145 ++++++++++++++++++ .../common/config/swagger/SwaggerConfig.java | 32 ++++ .../com/diggindie/vote/common/enums/Role.java | 13 ++ .../vote/common/response/PageInfo.java | 9 ++ .../vote/common/response/Response.java | 71 +++++++++ .../controller/CandidateController.java | 77 ++++++++++ .../domain/Candidate.java | 14 +- .../candidate/dto/CandidateApplyResponse.java | 9 ++ .../domain/candidate/dto/CandidateDto.java | 13 ++ .../candidate/dto/CandidateListResponse.java | 10 ++ .../candidate/dto/PartVoteRequestDto.java | 8 + .../repository/CandidateRepository.java | 18 +++ .../candidate/service/CandidateService.java | 106 +++++++++++++ .../candidate/service/PartVoteExecutor.java | 43 ++++++ .../member/controller/AuthController.java | 72 +++++++++ .../vote/domain/member/domain/Member.java | 37 +++++ .../vote/domain/member/dto/LoginRequest.java | 7 + .../vote/domain/member/dto/LoginResponse.java | 13 ++ .../domain/member/dto/LogoutResponse.java | 6 + .../vote/domain/member/dto/SignupRequest.java | 13 ++ .../domain/member/dto/SignupResponse.java | 13 ++ .../member/repository/MemberRepository.java | 18 +++ .../domain/member/service/AuthService.java | 130 ++++++++++++++++ .../team/controller/TeamController.java | 59 +++++++ .../vote/domain/team/domain/Team.java | 11 ++ .../vote/domain/team/dto/TeamDto.java | 9 ++ .../domain/team/dto/TeamListResponse.java | 9 ++ .../domain/team/dto/TeamVoteRequestDto.java | 8 + .../team/repository/TeamRepository.java | 13 ++ .../vote/domain/team/service/TeamService.java | 65 ++++++++ .../domain/team/service/TeamVoteExecutor.java | 39 +++++ .../vote/domain/vote/domain/PartVote.java | 28 ---- .../vote/domain/vote/domain/TeamVote.java | 29 ---- src/main/resources/application.yml | 24 ++- 48 files changed, 1528 insertions(+), 76 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/main/java/com/diggindie/vote/common/code/Code.java create mode 100644 src/main/java/com/diggindie/vote/common/code/ErrorCode.java create mode 100644 src/main/java/com/diggindie/vote/common/code/SuccessCode.java create mode 100644 src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java create mode 100644 src/main/java/com/diggindie/vote/common/enums/Role.java create mode 100644 src/main/java/com/diggindie/vote/common/response/PageInfo.java create mode 100644 src/main/java/com/diggindie/vote/common/response/Response.java create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java rename src/main/java/com/diggindie/vote/domain/{member => candidate}/domain/Candidate.java (56%) create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java create mode 100644 src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/service/AuthService.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/service/TeamService.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java delete mode 100644 src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6dfac53..9ebc07c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -55,13 +55,38 @@ jobs: username: ec2-user key: ${{ secrets.EC2_SSH_KEY }} script: | + mkdir -p /home/ec2-user/app + cd /home/ec2-user/app + + cat > docker-compose.yml << 'EOF' + version: '3.8' + services: + app: + image: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest + ports: + - "8080:8080" + environment: + - DB_HOST=${{ secrets.DB_HOST }} + - DB_PORT=5432 + - DB_NAME=${{ secrets.DB_NAME }} + - DB_USERNAME=${{ secrets.DB_USERNAME }} + - DB_PASSWORD=${{ secrets.DB_PASSWORD }} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:alpine + ports: + - "6379:6379" + restart: unless-stopped + EOF + aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com - docker pull ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest - docker stop spring-vote || true - docker rm spring-vote || true - docker run -d --name spring-vote -p 8080:8080 \ - -e DB_HOST=${{ secrets.DB_HOST }} \ - -e DB_NAME=${{ secrets.DB_NAME }} \ - -e DB_USERNAME=${{ secrets.DB_USERNAME }} \ - -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \ - ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest \ No newline at end of file + + docker-compose down || true + docker-compose pull + docker-compose up -d \ No newline at end of file diff --git a/.gitignore b/.gitignore index 983a5f7..d607a29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ HELP.md +.env .gradle build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/build.gradle b/build.gradle index fa6f155..d26e2fc 100644 --- a/build.gradle +++ b/build.gradle @@ -25,15 +25,38 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-webmvc' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Spring Boot Starter + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Database + runtimeOnly 'org.postgresql:postgresql' + + // Lombok compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' implementation 'org.postgresql:postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + + // reddison + implementation 'org.redisson:redisson:3.27.0' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e7b68fa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' +services: + app: + build: . + ports: + - "8080:8080" + environment: + - DB_HOST=${DB_HOST} + - DB_PORT=${DB_PORT} + - DB_NAME=${DB_NAME} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - JWT_SECRET_KEY=${JWT_SECRET_KEY} + depends_on: + - redis + + redis: + image: redis:alpine + ports: + - "6379:6379" \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/code/Code.java b/src/main/java/com/diggindie/vote/common/code/Code.java new file mode 100644 index 0000000..9ec06a6 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/Code.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.common.code; + +public interface Code { + int getStatusCode(); + String getMessage(); +} diff --git a/src/main/java/com/diggindie/vote/common/code/ErrorCode.java b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java new file mode 100644 index 0000000..33a6890 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java @@ -0,0 +1,19 @@ +package com.diggindie.vote.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements Code{ + + // 인증 실패 + UNAUTHORIZED_ERROR(401, "Unauthorized Exception"), + + // 권한 없음 + FORBIDDEN_ERROR(403, "Forbidden Exception"); + + private final int statusCode; + private final String message; + +} diff --git a/src/main/java/com/diggindie/vote/common/code/SuccessCode.java b/src/main/java/com/diggindie/vote/common/code/SuccessCode.java new file mode 100644 index 0000000..8d7868a --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/SuccessCode.java @@ -0,0 +1,19 @@ +package com.diggindie.vote.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessCode implements Code { + + GET_SUCCESS(200, "GET_SUCCESS"), + LOGIN_SUCCESS(200, "LOGIN_SUCCESS"), + DELETE_SUCCESS(200, "DELETE_SUCCESS"), + INSERT_SUCCESS(201, "INSERT_SUCCESS"), + UPDATE_SUCCESS(204, "UPDATE_SUCCESS"); + + private final int statusCode; + private final String message; + +} diff --git a/src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java b/src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java new file mode 100644 index 0000000..b36c8b6 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java @@ -0,0 +1,28 @@ +package com.diggindie.vote.common.config.reddison; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://" + redisHost + ":" + redisPort) + .setConnectionMinimumIdleSize(1) + .setConnectionPoolSize(2); + return Redisson.create(config); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java new file mode 100644 index 0000000..6fc2666 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java @@ -0,0 +1,33 @@ +package com.diggindie.vote.common.config.security; + +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CustomUserDetailService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public CustomUserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { + + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("Member not found")); + + return new CustomUserDetails(member.getId(), member.getExternalId(), member.getRole()); + } + + + public CustomUserDetails loadByExternalId(String externalId) { + + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new UsernameNotFoundException("Member not found")); + + return new CustomUserDetails(member.getId(), member.getExternalId(), member.getRole()); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java new file mode 100644 index 0000000..ebf1740 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java @@ -0,0 +1,60 @@ +package com.diggindie.vote.common.config.security; + +import com.diggindie.vote.common.enums.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Long memberId; + private final String externalId; + private final Role role; + + public CustomUserDetails(Long memberId, String externalId, Role role) { + this.memberId = memberId; + this.externalId = externalId; + this.role = role; + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(role.name())); + } + + @Override + public String getUsername() { + return externalId; + } + + public String getExternalId() { return externalId; } + + public Long getMemberId() { + return memberId; + } + + @Override public String getPassword() { + return null; + } + + @Override public boolean isAccountNonExpired() { + return true; + } + + @Override public boolean isAccountNonLocked() { + return true; + } + + @Override public boolean isCredentialsNonExpired() { + return true; + } + + @Override public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java new file mode 100644 index 0000000..de96f02 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java @@ -0,0 +1,59 @@ +package com.diggindie.vote.common.config.security; + + +import com.diggindie.vote.common.config.security.jwt.JwtAccessDeniedHandler; +import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationEntryPoint; +import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationFilter; +import com.diggindie.vote.common.config.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint authenticationEntryPoint; + private final JwtAccessDeniedHandler accessDeniedHandler; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + JwtTokenProvider jwtTokenProvider + + ) throws Exception { + + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + ) + .addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..b545cd4 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,37 @@ +package com.diggindie.vote.common.config.security.jwt; + +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.response.Response; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + Response body = Response.of(ErrorCode.FORBIDDEN_ERROR, false, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + response.getWriter().flush(); + } + +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..4fbfd2e --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.diggindie.vote.common.config.security.jwt; + + +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.response.Response; +import tools.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + Response body = Response.of(ErrorCode.UNAUTHORIZED_ERROR, false, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + response.getWriter().flush(); + + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3a0db64 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,37 @@ +package com.diggindie.vote.common.config.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = jwtTokenProvider.getAccessToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..4c00360 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java @@ -0,0 +1,145 @@ +package com.diggindie.vote.common.config.security.jwt; + + +import com.diggindie.vote.common.config.security.CustomUserDetailService; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.enums.Role; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.security.Key; +import java.time.Duration; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider implements InitializingBean { + + @Value("${jwt.secret-key}") + private String secretKey; + + @Value("${jwt.access-token-validity}") + private Duration accessTokenValidity; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + private Key key; + + private final CustomUserDetailService customUserDetailService; + + @Override + public void afterPropertiesSet() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String generateAccessToken(String externalId, Role role) { + return generateToken(externalId, role, accessTokenValidity); + } + + public String generateRefreshToken(String externalId, Role role) { + return generateToken(externalId, role, refreshTokenValidity); + } + + public String generateToken(String externalId, Role role, Duration expiration) { + + Date now = new Date(); + Date expiry = new Date(now.getTime() + expiration.toMillis()); + + return Jwts.builder() + .setSubject(externalId) + .claim("role", role.name()) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String getAccessToken(HttpServletRequest request) { + + // cookie 기반 토큰 추출 + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + + // header 기반 토큰 추출 + String bearerToken = request.getHeader("Authorization"); + + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + public Claims parseClaims(String token) { + + Claims claims = Jwts.parser() + .verifyWith((SecretKey)key) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims; + } + + public String getExternalId(String token) { + return parseClaims(token).getSubject(); + } + + public Role getRole(String token) { + String roleName = parseClaims(token).get("role", String.class); + return Role.valueOf(roleName); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); // 파싱과 동시에 검증 수행 + return true; + } catch (SecurityException | MalformedJwtException e) { + // 잘못된 서명 또는 JWT 형식 + log.warn("JWT 토큰 형식이 잘못되었습니다.", e); + } catch (ExpiredJwtException e) { + // 만료된 JWT + log.warn("만료된 JWT 토큰이 사용되었습니다.", e); + } catch (UnsupportedJwtException e) { + // 지원하지 않는 JWT + log.warn("지원하지 않는 JWT 토큰이 사용되었습니다.", e); + } catch (IllegalArgumentException e) { + // 빈 JWT 또는 기타 문제 + log.warn("JWT 토큰의 값이 비어있습니다.", e); + } + return false; + } + + public Authentication getAuthentication(String token) { + + String externalId = getExternalId(token); + Role role = getRole(token); + + CustomUserDetails userDetails = customUserDetailService.loadByExternalId(externalId); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } + +} diff --git a/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java new file mode 100644 index 0000000..67a7cfe --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java @@ -0,0 +1,32 @@ +package com.diggindie.vote.common.config.swagger; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "DiggIndie Vote API", + version = "v1", + description = "Voting service for CEOS 22nd" + ), + servers = { + @Server(url = "http://localhost:8080", description = "local") + // 배포 서버 있으면 추가 + // @Server(url = "https://api.yourdomain.com", description = "prod") + }, + security = @SecurityRequirement(name = "bearerAuth") +) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" +) +public class SwaggerConfig { +} diff --git a/src/main/java/com/diggindie/vote/common/enums/Role.java b/src/main/java/com/diggindie/vote/common/enums/Role.java new file mode 100644 index 0000000..bc38c24 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/enums/Role.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.common.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + ROLE_USER("일반 사용자"), + ROLE_ADMIN("관리자"); + + private final String description; +} diff --git a/src/main/java/com/diggindie/vote/common/response/PageInfo.java b/src/main/java/com/diggindie/vote/common/response/PageInfo.java new file mode 100644 index 0000000..a90a8e5 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/response/PageInfo.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.common.response; + +public record PageInfo( + int page, + int size, + boolean hasNext, + long totalElements, + int totalPages +) {} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/response/Response.java b/src/main/java/com/diggindie/vote/common/response/Response.java new file mode 100644 index 0000000..bf00e47 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/response/Response.java @@ -0,0 +1,71 @@ +package com.diggindie.vote.common.response; + + +import com.diggindie.vote.common.code.Code; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Response { + + private int statusCode; + + @Getter(AccessLevel.NONE) + private boolean isSuccess; + + private String message; + + private PageInfo pageInfo; + + private T payload; + + @JsonProperty("isSuccess") + public boolean isSuccess() { + return isSuccess; + } + + // Non-paginated + public static Response of(Code code, boolean isSuccess, T payload) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(code.getMessage()) + .payload(payload) + .build(); + } + + public static Response of(Code code, boolean isSuccess, String message, T payload) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(message) + .payload(payload) + .build(); + } + + // Paginated + public static Response of(Code code, boolean isSuccess, T payload, PageInfo pageInfo) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(code.getMessage()) + .pageInfo(pageInfo) + .payload(payload) + .build(); + } + + public static Response of(Code code, boolean isSuccess, String message, T payload, PageInfo pageInfo) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(message) + .pageInfo(pageInfo) + .payload(payload) + .build(); + } +} diff --git a/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java new file mode 100644 index 0000000..7daf3f3 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java @@ -0,0 +1,77 @@ +package com.diggindie.vote.domain.candidate.controller; + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.candidate.dto.CandidateApplyResponse; +import com.diggindie.vote.domain.candidate.dto.CandidateListResponse; +import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; +import com.diggindie.vote.domain.candidate.service.CandidateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class CandidateController { + + private final CandidateService candidateService; + + @PreAuthorize("isAuthenticated()") + @GetMapping("/candidates") + public ResponseEntity> getCandidates( + @RequestParam("part") Part part + ) { + return ResponseEntity.ok().body(Response.of( + SuccessCode.GET_SUCCESS, + true, + "파트장 후보 반환 API", + candidateService.getCandidatesByPart(part) + )); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/votes/leaders/results") + public ResponseEntity> getCandidatesVote( + @RequestParam("part") Part part + ) { + return ResponseEntity.ok().body(Response.of( + SuccessCode.GET_SUCCESS, + true, + "파트장 투표 결과 반환 API", + candidateService.getCandidateVoteByPart(part) + )); + } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/candidates/apply") + public ResponseEntity> applyCandidate( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok().body(Response.of( + SuccessCode.GET_SUCCESS, + true, + "파트장 후보 등록 API", + candidateService.applyCandidate(userDetails.getMemberId()) + )); + } + + @PostMapping("/votes/leaders") + public ResponseEntity> voteCandidate( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody PartVoteRequestDto request + ) { + candidateService.vote(userDetails.getExternalId(), request); + return ResponseEntity.ok().body(Response.of( + SuccessCode.INSERT_SUCCESS, + true, + "파트장 투표 완료", + (Void) null + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java b/src/main/java/com/diggindie/vote/domain/candidate/domain/Candidate.java similarity index 56% rename from src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java rename to src/main/java/com/diggindie/vote/domain/candidate/domain/Candidate.java index 7f855f0..bcb0150 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Candidate.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/domain/Candidate.java @@ -1,5 +1,6 @@ -package com.diggindie.vote.domain.member.domain; +package com.diggindie.vote.domain.candidate.domain; +import com.diggindie.vote.domain.member.domain.Member; import jakarta.persistence.*; import lombok.*; @@ -18,8 +19,19 @@ public class Candidate { @JoinColumn(name = "member_id", nullable = false, unique = true) private Member member; + @Column(name = "vote_count", nullable = false) + private Integer voteCount = 0; + public Candidate(Member member) { this.member = member; + this.voteCount = 0; + } + + public void increaseVoteCount() { + this.voteCount++; } + public void decreaseVoteCount() { + if (this.voteCount > 0) this.voteCount--; + } } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java new file mode 100644 index 0000000..116d0f2 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.candidate.dto; + +public record CandidateApplyResponse( + Long candidateId, + String candidateName, + String candidatePart +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java new file mode 100644 index 0000000..20b9c5f --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.candidate.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record CandidateDto( + Long candidateId, + String candidateName, + String candidatePart, + Long currentVote +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java new file mode 100644 index 0000000..8c3b9cf --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java @@ -0,0 +1,10 @@ +package com.diggindie.vote.domain.candidate.dto; + +import java.util.List; + +public record CandidateListResponse( + String part, + List candidates +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java new file mode 100644 index 0000000..aba126a --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.candidate.dto; + +import jakarta.validation.constraints.NotNull; + +public record PartVoteRequestDto( + @NotNull(message = "후보자 ID는 필수입니다") + Long candidateId +) {} diff --git a/src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java b/src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java new file mode 100644 index 0000000..fd4d381 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java @@ -0,0 +1,18 @@ +package com.diggindie.vote.domain.candidate.repository; + +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.domain.candidate.domain.Candidate; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CandidateRepository extends JpaRepository { + + @Query("SELECT c FROM Candidate c JOIN FETCH c.member m WHERE m.part = :part") + List findAllByPart(@Param("part") Part part); +} + diff --git a/src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java b/src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java new file mode 100644 index 0000000..6f6aac2 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java @@ -0,0 +1,106 @@ +package com.diggindie.vote.domain.candidate.service; + +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.domain.candidate.domain.Candidate; +import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.candidate.dto.CandidateApplyResponse; +import com.diggindie.vote.domain.candidate.dto.CandidateDto; +import com.diggindie.vote.domain.candidate.dto.CandidateListResponse; +import com.diggindie.vote.domain.candidate.repository.CandidateRepository; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CandidateService { + + private final CandidateRepository candidateRepository; + private final MemberRepository memberRepository; + private final RedissonClient redissonClient; + private final PartVoteExecutor partVoteExecutor; + + private static final String PART_VOTE_LOCK_PREFIX = "vote:part:lock:"; + + public CandidateListResponse getCandidatesByPart(Part part) { + List candidates = candidateRepository.findAllByPart(part); + + List candidateDtos = candidates.stream() + .map(candidate -> new CandidateDto( + candidate.getId(), + candidate.getMember().getMemberName(), + candidate.getMember().getPart().toString(), + null // 투표 전에는 득표수 숨김 + )) + .toList(); + + return new CandidateListResponse(part.toString(), candidateDtos); + } + + public CandidateListResponse getCandidateVoteByPart(Part part) { + List candidates = candidateRepository.findAllByPart(part); + + List candidateDtos = candidates.stream() + .map(candidate -> new CandidateDto( + candidate.getId(), + candidate.getMember().getMemberName(), + candidate.getMember().getPart().toString(), + (long) candidate.getVoteCount() // 엔티티에서 직접 조회 + )) + .toList(); + + return new CandidateListResponse(part.toString(), candidateDtos); + } + + @Transactional(readOnly = false) + public CandidateApplyResponse applyCandidate(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.getCandidate() != null) { + throw new IllegalArgumentException("이미 파트장 후보로 등록되어 있습니다."); + } + + Candidate candidate = new Candidate(member); + Candidate savedCandidate = candidateRepository.save(candidate); + + candidateRepository.flush(); + + return new CandidateApplyResponse( + savedCandidate.getId(), + member.getMemberName(), + member.getPart().toString() + ); + } + + public void vote(String externalId, PartVoteRequestDto request) { + String lockKey = PART_VOTE_LOCK_PREFIX + request.candidateId(); + RLock lock = redissonClient.getLock(lockKey); + + try { + boolean acquired = lock.tryLock(3, 15, TimeUnit.SECONDS); + if (!acquired) { + throw new IllegalStateException("요청이 많습니다. 잠시 후 다시 시도해주세요."); + } + + partVoteExecutor.execute(externalId, request); + log.info("파트장 투표 완료 - externalId: {}, candidateId: {}", externalId, request.candidateId()); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("투표 처리 중 오류가 발생했습니다."); + } finally { + if (lock.isHeldByCurrentThread()) lock.unlock(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java b/src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java new file mode 100644 index 0000000..3a65d65 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java @@ -0,0 +1,43 @@ +package com.diggindie.vote.domain.candidate.service; + +import com.diggindie.vote.domain.candidate.domain.Candidate; +import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; +import com.diggindie.vote.domain.candidate.repository.CandidateRepository; +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PartVoteExecutor { + + private final MemberRepository memberRepository; + private final CandidateRepository candidateRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void execute(String externalId, PartVoteRequestDto request) { + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.isHasVotedCandidate()) { + throw new IllegalStateException("이미 파트장 투표를 완료하였습니다."); + } + + Candidate candidate = candidateRepository.findById(request.candidateId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 후보자입니다.")); + + if (candidate.getMember().getId().equals(member.getId())) { + throw new IllegalStateException("자기 자신에게는 투표할 수 없습니다."); + } + + if (member.getPart() != candidate.getMember().getPart()) { + throw new IllegalStateException("같은 파트의 후보자에게만 투표할 수 있습니다."); + } + + candidate.increaseVoteCount(); + member.markCandidateVoted(); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java new file mode 100644 index 0000000..86cbd99 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java @@ -0,0 +1,72 @@ +package com.diggindie.vote.domain.member.controller; + + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.member.dto.LoginRequest; +import com.diggindie.vote.domain.member.dto.LoginResponse; +import com.diggindie.vote.domain.member.dto.LogoutResponse; +import com.diggindie.vote.domain.member.dto.SignupRequest; +import com.diggindie.vote.domain.member.dto.SignupResponse; +import com.diggindie.vote.domain.member.service.AuthService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/auth/signup") + public ResponseEntity> signup( + @RequestBody SignupRequest signupRequest, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.INSERT_SUCCESS, + true, + "회원 가입 API", + authService.signup(signupRequest, httpResponse) + ); + return ResponseEntity.ok().body(response); + } + + @PostMapping("/auth/login") + public ResponseEntity> login( + @RequestBody LoginRequest loginRequest, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "일반 로그인 API", + authService.login(loginRequest, httpResponse) + ); + return ResponseEntity.ok().body(response); + } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/auth/logout") + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "로그아웃 API", + authService.logout(httpResponse, userDetails.getExternalId()) + ); + return ResponseEntity.ok().body(response); + } + +} diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java index 76634df..29f581a 100644 --- a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -1,10 +1,14 @@ package com.diggindie.vote.domain.member.domain; import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.enums.Role; +import com.diggindie.vote.domain.candidate.domain.Candidate; import com.diggindie.vote.domain.team.domain.Team; import jakarta.persistence.*; import lombok.*; +import java.util.UUID; + @Entity @Table(name = "member") @Getter @@ -16,6 +20,12 @@ public class Member { @Column(name = "member_id") private Long id; + @Column(name = "external_id", nullable = false, length = 36, unique = true) + private String externalId; + + @Enumerated(EnumType.STRING) + private Role role; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id", nullable = false) private Team team; @@ -39,4 +49,31 @@ public class Member { @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private Candidate candidate; + @Column(name = "has_voted_team", nullable = false) + private boolean hasVotedTeam = false; + + @Column(name = "has_voted_candidate", nullable = false) + private boolean hasVotedCandidate = false; + + @Builder + public Member(Role role, Team team, Part part, String loginId, String email, String password, String memberName) { + this.externalId = UUID.randomUUID().toString(); + this.role = Role.ROLE_USER; + this.team = team; + this.part = part; + this.loginId = loginId; + this.email = email; + this.password = password; + this.memberName = memberName; + this.hasVotedTeam = false; + this.hasVotedCandidate = false; + } + + public void markTeamVoted() { + this.hasVotedTeam = true; + } + + public void markCandidateVoted() { + this.hasVotedCandidate = true; + } } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java b/src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java new file mode 100644 index 0000000..e7e40d9 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java @@ -0,0 +1,7 @@ +package com.diggindie.vote.domain.member.dto; + +public record LoginRequest( + String loginId, + String password +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java new file mode 100644 index 0000000..10f9741 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.diggindie.vote.common.enums.Part; + +public record LoginResponse( + String memberId, + String name, + Part part, + String team, + String accessToken, + long expiresIn +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java new file mode 100644 index 0000000..0382734 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.domain.member.dto; + +public record LogoutResponse( + String memberId +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java b/src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java new file mode 100644 index 0000000..5899460 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.diggindie.vote.common.enums.Part; + +public record SignupRequest( + String loginId, + String password, + String email, + Part part, + String name, + String team +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java new file mode 100644 index 0000000..2156f79 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.diggindie.vote.common.enums.Part; + +public record SignupResponse( + String memberId, + String name, + Part part, + String team, + String accessToken, + long expiresIn +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..1ac3b47 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java @@ -0,0 +1,18 @@ +package com.diggindie.vote.domain.member.repository; + +import com.diggindie.vote.domain.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + Optional findByExternalId(String externalId); + + boolean existsByLoginId(String loginId); + +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java new file mode 100644 index 0000000..a05e065 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java @@ -0,0 +1,130 @@ +package com.diggindie.vote.domain.member.service; + +import com.diggindie.vote.common.config.security.jwt.JwtTokenProvider; +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.enums.Role; +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.dto.*; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import com.diggindie.vote.domain.team.repository.TeamRepository; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepository; + private final TeamRepository teamRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${jwt.access-token-validity}") + private Duration accessTokenValidity; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + @Transactional(readOnly = true) + public LoginResponse login(LoginRequest request, HttpServletResponse response) { + + Member member = memberRepository.findByLoginId(request.loginId()) + .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다.")); + + if (!passwordEncoder.matches(request.password(), member.getPassword())) { + throw new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다."); + } + + String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); + setCookies(response, member.getExternalId(), member.getRole()); + + + return new LoginResponse( + member.getExternalId(), + member.getMemberName(), + member.getPart(), + member.getTeam().getTeamName(), + accessToken, + accessTokenValidity.getSeconds() + ); + } + + @Transactional + public SignupResponse signup(SignupRequest request, HttpServletResponse response) { + + if (memberRepository.existsByLoginId(request.loginId())) { + throw new IllegalArgumentException("이미 사용 중인 아이디입니다."); + } + + String encodedPassword = passwordEncoder.encode(request.password()); + + Member member = Member.builder() + .loginId(request.loginId()) + .password(encodedPassword) + .email(request.email()) + .part(request.part()) + .memberName(request.name()) + .team(teamRepository.findByTeamName(request.team()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 팀입니다."))) + .build(); + + Member savedMember = memberRepository.save(member); + + String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); + setCookies(response, savedMember.getExternalId(), savedMember.getRole()); + + return new SignupResponse( + savedMember.getExternalId(), + savedMember.getMemberName(), + savedMember.getPart(), + savedMember.getTeam().getTeamName(), + accessToken, + accessTokenValidity.getSeconds() + ); + } + + @Transactional(readOnly = true) + public LogoutResponse logout(HttpServletResponse response, String externalId) { + + removeRefreshTokenCookie(response); + return new LogoutResponse(externalId); + } + + private void removeRefreshTokenCookie(HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .path("/") + .maxAge(0) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + private void setCookies(HttpServletResponse response, String externalId, Role role) { + String refreshToken = jwtTokenProvider.generateRefreshToken(externalId, role); + addTokenCookie(response, "refreshToken", refreshToken, refreshTokenValidity); + } + + private void addTokenCookie(HttpServletResponse response, String name, String value, Duration maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .path("/") + .maxAge(maxAge) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + +} diff --git a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java new file mode 100644 index 0000000..bf9fbfa --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java @@ -0,0 +1,59 @@ +package com.diggindie.vote.domain.team.controller; + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.team.dto.TeamListResponse; +import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.service.TeamService; +import com.diggindie.vote.domain.team.service.TeamVoteExecutor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RestController; + +import java.util.concurrent.TimeUnit; + +@RestController +@Slf4j +@RequiredArgsConstructor +public class TeamController { + + private final TeamService teamService; + + @PreAuthorize("isAuthenticated()") + @GetMapping("/teams") + public ResponseEntity> getTeamList() { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "팀 목록 반환 API", + teamService.getTeamList() + ); + return ResponseEntity.ok().body(response); + } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/votes/teams") + public ResponseEntity> voteTeam( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody TeamVoteRequestDto request + ) { + teamService.vote(userDetails.getExternalId(), request); // getUserId() → getExternalId() + return ResponseEntity.ok().body(Response.of( + SuccessCode.INSERT_SUCCESS, + true, + "팀 투표 완료", + (Void) null + )); + } +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java index 5c4afba..456c181 100644 --- a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java +++ b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java @@ -23,6 +23,17 @@ public class Team { @Column(name = "proposal", length = 200) private String proposal; + @Column(name = "vote_count", nullable = false) + private Integer voteCount = 0; + @OneToMany(mappedBy = "team") private List members = new ArrayList<>(); + + public void increaseVoteCount() { + this.voteCount++; + } + + public void decreaseVoteCount() { + if (this.voteCount > 0) this.voteCount--; + } } \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java new file mode 100644 index 0000000..c405a2c --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.team.dto; + +public record TeamDto( + Long teamId, + String teamName, + String teamProposal +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java new file mode 100644 index 0000000..94d6cad --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.team.dto; + +import java.util.List; + +public record TeamListResponse( + List teams +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java new file mode 100644 index 0000000..ce2fe8a --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.team.dto; + +import jakarta.validation.constraints.NotNull; + +public record TeamVoteRequestDto( + @NotNull(message = "팀 ID는 필수입니다") + Long teamId +) {} diff --git a/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java new file mode 100644 index 0000000..c1a16cc --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.team.repository; + +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.team.domain.Team; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TeamRepository extends JpaRepository { + + Optional findByTeamName(String teamName); + +} diff --git a/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java new file mode 100644 index 0000000..f8d92d8 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java @@ -0,0 +1,65 @@ +package com.diggindie.vote.domain.team.service; + +import com.diggindie.vote.domain.team.domain.Team; +import com.diggindie.vote.domain.team.dto.TeamDto; +import com.diggindie.vote.domain.team.dto.TeamListResponse; +import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TeamService { + + private final TeamRepository teamRepository; + private final RedissonClient redissonClient; + private final TeamVoteExecutor teamVoteExecutor; + + private static final String TEAM_VOTE_LOCK_PREFIX = "vote:team:lock:"; + + public TeamListResponse getTeamList() { + List teams = teamRepository.findAll(); + + List teamDtos = teams.stream() + .map(team -> new TeamDto( + team.getId(), + team.getTeamName(), + team.getProposal() + )) + .toList(); + + return new TeamListResponse(teamDtos); + } + + public void vote(String externalId, TeamVoteRequestDto request) { + String lockKey = TEAM_VOTE_LOCK_PREFIX + request.teamId(); + RLock lock = redissonClient.getLock(lockKey); + + try { + boolean acquired = lock.tryLock(3, 15, TimeUnit.SECONDS); + if (!acquired) { + throw new IllegalStateException("요청이 많습니다. 잠시 후 다시 시도해주세요."); + } + + teamVoteExecutor.execute(externalId, request); + log.info("팀 투표 완료 - externalId: {}, teamId: {}", externalId, request.teamId()); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("투표 처리 중 오류가 발생했습니다."); + } finally { + if (lock.isHeldByCurrentThread()) lock.unlock(); + } + } +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java b/src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java new file mode 100644 index 0000000..55ddd44 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java @@ -0,0 +1,39 @@ +package com.diggindie.vote.domain.team.service; + +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import com.diggindie.vote.domain.team.domain.Team; +import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TeamVoteExecutor { + + private final MemberRepository memberRepository; + private final TeamRepository teamRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void execute(String externalId, TeamVoteRequestDto request) { + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.isHasVotedTeam()) { + throw new IllegalStateException("이미 팀 투표를 완료하셨습니다."); + } + + Team team = teamRepository.findById(request.teamId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 팀입니다.")); + + if (member.getTeam().getId().equals(team.getId())) { + throw new IllegalStateException("자신이 소속한 팀에는 투표할 수 없습니다."); + } + + team.increaseVoteCount(); + member.markTeamVoted(); + } +} diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java deleted file mode 100644 index e71574c..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/domain/PartVote.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.diggindie.vote.domain.vote.domain; - -import com.diggindie.vote.domain.member.domain.Candidate; -import com.diggindie.vote.domain.member.domain.Member; -import jakarta.persistence.*; -import lombok.*; - -@Entity -@Table(name = "part_vote", indexes = { - @Index(name = "idx_part_vote_candidate", columnList = "candidate_id") -}) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PartVote { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "part_vote_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "voter_member_id", nullable = false, unique = true) - private Member voter; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "candidate_id", nullable = false) - private Candidate candidate; -} diff --git a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java b/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java deleted file mode 100644 index 6d94a1d..0000000 --- a/src/main/java/com/diggindie/vote/domain/vote/domain/TeamVote.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.diggindie.vote.domain.vote.domain; - -import com.diggindie.vote.domain.team.domain.Team; -import com.diggindie.vote.domain.member.domain.Member; -import jakarta.persistence.*; -import lombok.*; - -@Entity -@Table(name = "team_vote", indexes = { - @Index(name = "idx_team_vote_team", columnList = "team_id") -}) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class TeamVote { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "team_vote_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "voter_member_id", nullable = false, unique = true) - private Member voter; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "team_id", nullable = false) - private Team team; -} - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 515b2b1..dcdeec7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,21 +3,35 @@ spring: name: vote datasource: - url: jdbc:postgresql://${DB_HOST:localhost}:5432/${DB_NAME:springvote} + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:vote} username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:password} driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 jpa: hibernate: - ddl-auto: create + ddl-auto: ${DDL_AUTO:update} + show-sql: true properties: hibernate: - format_sql: true - show_sql: true dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} logging: level: org.hibernate.SQL: debug - org.hibernate.type.descriptor.sql: trace \ No newline at end of file + org.hibernate.type.descriptor.sql: trace + +jwt: + secret-key: ${JWT_SECRET_KEY:diggindievotingserviceeeeeeeeeeeee1234567890} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} \ No newline at end of file From a57fe41d106a1e9717f3422d781f67596814c6d5 Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Thu, 18 Dec 2025 15:16:16 +0900 Subject: [PATCH 37/52] =?UTF-8?q?feat=20:=20refresh=20token=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/jwt/JwtTokenProvider.java | 2 + .../member/controller/AuthController.java | 17 +++++++ .../member/dto/TokenReissueResponse.java | 8 ++++ .../domain/member/service/AuthService.java | 47 +++++++++++++++++-- .../member/service/RefreshTokenService.java | 47 +++++++++++++++++++ 5 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java index 4c00360..0dcd762 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java @@ -21,6 +21,7 @@ import java.security.Key; import java.time.Duration; import java.util.Date; +import java.util.UUID; @Slf4j @Component @@ -62,6 +63,7 @@ public String generateToken(String externalId, Role role, Duration expiration) { return Jwts.builder() .setSubject(externalId) .claim("role", role.name()) + .setId(UUID.randomUUID().toString()) .setIssuedAt(now) .setExpiration(expiry) .signWith(key, SignatureAlgorithm.HS256) diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java index 86cbd99..23071cd 100644 --- a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java +++ b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java @@ -9,7 +9,9 @@ import com.diggindie.vote.domain.member.dto.LogoutResponse; import com.diggindie.vote.domain.member.dto.SignupRequest; import com.diggindie.vote.domain.member.dto.SignupResponse; +import com.diggindie.vote.domain.member.dto.TokenReissueResponse; import com.diggindie.vote.domain.member.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -69,4 +71,19 @@ public ResponseEntity> logout( return ResponseEntity.ok().body(response); } + @PostMapping("/auth/reissue") + public ResponseEntity> reissue( + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "토큰 재발급 API", + authService.reissue(httpRequest, httpResponse) + ); + return ResponseEntity.ok().body(response); + } + } diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java new file mode 100644 index 0000000..8dacab7 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.member.dto; + +public record TokenReissueResponse( + String accessToken, + Long expiresIn +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java index a05e065..9a43adb 100644 --- a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java +++ b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java @@ -7,6 +7,8 @@ import com.diggindie.vote.domain.member.dto.*; import com.diggindie.vote.domain.member.repository.MemberRepository; import com.diggindie.vote.domain.team.repository.TeamRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -25,6 +27,7 @@ public class AuthService { private final TeamRepository teamRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; @Value("${jwt.access-token-validity}") private Duration accessTokenValidity; @@ -43,7 +46,7 @@ public LoginResponse login(LoginRequest request, HttpServletResponse response) { } String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); - setCookies(response, member.getExternalId(), member.getRole()); + setRefreshToken(response, member.getExternalId(), member.getRole()); return new LoginResponse( @@ -78,7 +81,7 @@ public SignupResponse signup(SignupRequest request, HttpServletResponse response Member savedMember = memberRepository.save(member); String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); - setCookies(response, savedMember.getExternalId(), savedMember.getRole()); + setRefreshToken(response, savedMember.getExternalId(), savedMember.getRole()); return new SignupResponse( savedMember.getExternalId(), @@ -92,11 +95,46 @@ public SignupResponse signup(SignupRequest request, HttpServletResponse response @Transactional(readOnly = true) public LogoutResponse logout(HttpServletResponse response, String externalId) { - + refreshTokenService.delete(externalId); removeRefreshTokenCookie(response); return new LogoutResponse(externalId); } + public TokenReissueResponse reissue(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = extractRefreshTokenFromCookie(request); + + if (refreshToken == null) { + throw new IllegalArgumentException("Refresh token이 존재하지 않습니다."); + } + + String externalId = jwtTokenProvider.parseClaims(refreshToken).getSubject(); + + if (!refreshTokenService.validate(externalId, refreshToken)) { + refreshTokenService.delete(externalId); + removeRefreshTokenCookie(response); + throw new IllegalArgumentException("재로그인이 필요합니다."); + } + + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + String newAccessToken = jwtTokenProvider.generateAccessToken(externalId, member.getRole()); + setRefreshToken(response, externalId, member.getRole()); + + return new TokenReissueResponse(newAccessToken, accessTokenValidity.getSeconds()); + } + + private String extractRefreshTokenFromCookie(HttpServletRequest request) { + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + private void removeRefreshTokenCookie(HttpServletResponse response) { ResponseCookie cookie = ResponseCookie.from("refreshToken", "") .httpOnly(true) @@ -109,8 +147,9 @@ private void removeRefreshTokenCookie(HttpServletResponse response) { response.addHeader("Set-Cookie", cookie.toString()); } - private void setCookies(HttpServletResponse response, String externalId, Role role) { + private void setRefreshToken(HttpServletResponse response, String externalId, Role role) { String refreshToken = jwtTokenProvider.generateRefreshToken(externalId, role); + refreshTokenService.save(externalId, refreshToken); addTokenCookie(response, "refreshToken", refreshToken, refreshTokenValidity); } diff --git a/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java b/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java new file mode 100644 index 0000000..fb9fd98 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java @@ -0,0 +1,47 @@ +package com.diggindie.vote.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RedissonClient redissonClient; + + private static final String KEY_PREFIX = "refresh_token:"; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + public void save(String externalId, String token) { + RBucket bucket = redissonClient.getBucket(KEY_PREFIX + externalId); + bucket.set(token, refreshTokenValidity.toMillis(), TimeUnit.MILLISECONDS); + } + + public String get(String externalId) { + RBucket bucket = redissonClient.getBucket(KEY_PREFIX + externalId); + return bucket.get(); + } + + public void delete(String externalId) { + RBucket bucket = redissonClient.getBucket(KEY_PREFIX + externalId); + bucket.delete(); + } + + public boolean validate(String externalId, String token) { + String storedToken = get(externalId); + return storedToken != null && storedToken.equals(token); + } + + public void rotate(String externalId, String newToken) { + save(externalId, newToken); + } +} + From 711fd0209e4e808347aab00a35c5f302e6bde28a Mon Sep 17 00:00:00 2001 From: Yoonji Date: Thu, 18 Dec 2025 16:44:56 +0900 Subject: [PATCH 38/52] =?UTF-8?q?fix:=20rds=20=ED=8D=BC=EB=B8=94=EB=A6=AD?= =?UTF-8?q?=20=EC=84=9C=EB=B8=8C=EB=84=B7=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/main.tf | 2 +- infra/modules/rds/main.tf | 6 ++++-- infra/modules/rds/variables.tf | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/infra/main.tf b/infra/main.tf index 8273644..4701b8a 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -40,7 +40,7 @@ module "rds" { project_name = var.project_name environment = var.environment vpc_id = module.vpc.vpc_id - private_subnet_ids = module.vpc.private_subnet_ids + public_subnet_ids = module.vpc.public_subnet_ids db_name = var.db_name db_username = var.db_username db_password = var.db_password diff --git a/infra/modules/rds/main.tf b/infra/modules/rds/main.tf index 60a8c02..b30b513 100644 --- a/infra/modules/rds/main.tf +++ b/infra/modules/rds/main.tf @@ -4,7 +4,7 @@ resource "aws_db_subnet_group" "main" { name = "${var.project_name}-${var.environment}-db-subnet-group" description = "Database subnet group" - subnet_ids = var.private_subnet_ids + subnet_ids = var.public_subnet_ids tags = { Name = "${var.project_name}-${var.environment}-db-subnet-group" @@ -33,7 +33,9 @@ resource "aws_db_instance" "main" { # 네트워크 설정 db_subnet_group_name = aws_db_subnet_group.main.name vpc_security_group_ids = [var.db_security_group_id] - publicly_accessible = false + + apply_immediately = true + publicly_accessible = true multi_az = false # 비용 절감을 위해 단일 AZ # 백업 설정 diff --git a/infra/modules/rds/variables.tf b/infra/modules/rds/variables.tf index 1692ed0..37b9409 100644 --- a/infra/modules/rds/variables.tf +++ b/infra/modules/rds/variables.tf @@ -15,8 +15,8 @@ variable "vpc_id" { type = string } -variable "private_subnet_ids" { - description = "프라이빗 서브넷 ID 목록" +variable "public_subnet_ids" { + description = "퍼블릭 서브넷 ID 목록" type = list(string) } From 1542b0b3b8383497925e068d7dfa26e275680bb9 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Thu, 18 Dec 2025 17:12:12 +0900 Subject: [PATCH 39/52] =?UTF-8?q?[DEPLOY]=20RDS=20=ED=8D=BC=EB=B8=94?= =?UTF-8?q?=EB=A6=AD=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=ED=9B=84=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 * [FEAT] 기본 세팅 (#8) * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 --------- Co-authored-by: Yoonji Lee * feat : 로그인 관련 API 구현 * feat : 팀 목록 반환 API 개발 * feat : 팀 토표 결과 반환 API 개발 * feat : 파트장 관련 API 개발 * fix : 코드 리뷰 반영 * fix : 코드 리뷰 반영 * [FEAT] swagger 세팅 및 동시성 제어 투표 API 개발, closes #14, 16, 18 (#19) * feat: swagger 관련 초기 세팅 * chore: candidate 패키지로 파일 이동 * refactor: 도메인 수정 * feat: 동시성 제어 * bug: externalId로 조회하도록 수정 * feat: 파트장 투표 관련 코드 추가 * feat: 팀 투표 API 개발 * fix: externalId 관련 수정 * fix: 락 기준 문제 수정 * feat: docker-compose 추가 및 배포시 redis 설정 추가 * fix: 로컬 개발용 docker-compose 수정 * chore: 로컬용 도커 컴포즈 env 파일 사용하도록 수정 * fix: rds 퍼블릭 서브넷으로 변경 --------- Co-authored-by: Hoyoung027 --- infra/main.tf | 2 +- infra/modules/rds/main.tf | 6 ++++-- infra/modules/rds/variables.tf | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/infra/main.tf b/infra/main.tf index 8273644..4701b8a 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -40,7 +40,7 @@ module "rds" { project_name = var.project_name environment = var.environment vpc_id = module.vpc.vpc_id - private_subnet_ids = module.vpc.private_subnet_ids + public_subnet_ids = module.vpc.public_subnet_ids db_name = var.db_name db_username = var.db_username db_password = var.db_password diff --git a/infra/modules/rds/main.tf b/infra/modules/rds/main.tf index 60a8c02..b30b513 100644 --- a/infra/modules/rds/main.tf +++ b/infra/modules/rds/main.tf @@ -4,7 +4,7 @@ resource "aws_db_subnet_group" "main" { name = "${var.project_name}-${var.environment}-db-subnet-group" description = "Database subnet group" - subnet_ids = var.private_subnet_ids + subnet_ids = var.public_subnet_ids tags = { Name = "${var.project_name}-${var.environment}-db-subnet-group" @@ -33,7 +33,9 @@ resource "aws_db_instance" "main" { # 네트워크 설정 db_subnet_group_name = aws_db_subnet_group.main.name vpc_security_group_ids = [var.db_security_group_id] - publicly_accessible = false + + apply_immediately = true + publicly_accessible = true multi_az = false # 비용 절감을 위해 단일 AZ # 백업 설정 diff --git a/infra/modules/rds/variables.tf b/infra/modules/rds/variables.tf index 1692ed0..37b9409 100644 --- a/infra/modules/rds/variables.tf +++ b/infra/modules/rds/variables.tf @@ -15,8 +15,8 @@ variable "vpc_id" { type = string } -variable "private_subnet_ids" { - description = "프라이빗 서브넷 ID 목록" +variable "public_subnet_ids" { + description = "퍼블릭 서브넷 ID 목록" type = list(string) } From d946f2ef5e29980a261cb2016e824ce9944332da Mon Sep 17 00:00:00 2001 From: Yoonji Date: Thu, 18 Dec 2025 17:38:24 +0900 Subject: [PATCH 40/52] =?UTF-8?q?fix:=205432=20=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/modules/vpc/main.tf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/infra/modules/vpc/main.tf b/infra/modules/vpc/main.tf index 7fbbb56..c36aaa5 100644 --- a/infra/modules/vpc/main.tf +++ b/infra/modules/vpc/main.tf @@ -114,6 +114,14 @@ resource "aws_security_group" "web" { cidr_blocks = ["0.0.0.0/0"] } + ingress { + description = "PostgreSQL" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + # 모든 아웃바운드 허용 egress { from_port = 0 From 9729b89de2779e42ad14a7d9e9d12a48410007cc Mon Sep 17 00:00:00 2001 From: Yoonji Date: Thu, 18 Dec 2025 18:07:19 +0900 Subject: [PATCH 41/52] =?UTF-8?q?docs:=20swagger=EC=97=90=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=84=9C=EB=B2=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../diggindie/vote/common/config/swagger/SwaggerConfig.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java index 67a7cfe..b49a7ea 100644 --- a/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java @@ -16,9 +16,8 @@ description = "Voting service for CEOS 22nd" ), servers = { - @Server(url = "http://localhost:8080", description = "local") - // 배포 서버 있으면 추가 - // @Server(url = "https://api.yourdomain.com", description = "prod") + @Server(url = "http://localhost:8080", description = "local"), + @Server(url = "http://13.124.225.185:8080", description = "prod") }, security = @SecurityRequirement(name = "bearerAuth") ) From d6d0945819f5ab66c0cbb4bd9952393b321b5edc Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Thu, 18 Dec 2025 19:46:43 +0900 Subject: [PATCH 42/52] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../diggindie/vote/common/code/ErrorCode.java | 6 +++- .../config/security/jwt/JwtTokenProvider.java | 32 +++++++++++++------ .../common/exception/CustomException.java | 22 +++++++++++++ .../domain/member/service/AuthService.java | 1 + .../member/service/RefreshTokenService.java | 4 --- 5 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/diggindie/vote/common/exception/CustomException.java diff --git a/src/main/java/com/diggindie/vote/common/code/ErrorCode.java b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java index 33a6890..9ededba 100644 --- a/src/main/java/com/diggindie/vote/common/code/ErrorCode.java +++ b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java @@ -11,7 +11,11 @@ public enum ErrorCode implements Code{ UNAUTHORIZED_ERROR(401, "Unauthorized Exception"), // 권한 없음 - FORBIDDEN_ERROR(403, "Forbidden Exception"); + FORBIDDEN_ERROR(403, "Forbidden Exception"), + + // JWT / Token errors + EXPIRED_TOKEN(401, "Expired JWT token"), + INVALID_TOKEN(401, "Invalid JWT token"); private final int statusCode; private final String message; diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java index 0dcd762..fda5ad3 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java @@ -3,6 +3,8 @@ import com.diggindie.vote.common.config.security.CustomUserDetailService; import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.exception.CustomException; import com.diggindie.vote.common.enums.Role; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; @@ -21,7 +23,6 @@ import java.security.Key; import java.time.Duration; import java.util.Date; -import java.util.UUID; @Slf4j @Component @@ -63,7 +64,6 @@ public String generateToken(String externalId, Role role, Duration expiration) { return Jwts.builder() .setSubject(externalId) .claim("role", role.name()) - .setId(UUID.randomUUID().toString()) .setIssuedAt(now) .setExpiration(expiry) .signWith(key, SignatureAlgorithm.HS256) @@ -92,13 +92,27 @@ public String getAccessToken(HttpServletRequest request) { public Claims parseClaims(String token) { - Claims claims = Jwts.parser() - .verifyWith((SecretKey)key) - .build() - .parseSignedClaims(token) - .getPayload(); - - return claims; + try { + return Jwts.parser() + .verifyWith((SecretKey)key) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + // 만료된 토큰 + log.warn("만료된 JWT 토큰이 사용되었습니다.", e); + throw new CustomException(ErrorCode.EXPIRED_TOKEN); + } catch (SecurityException | MalformedJwtException e) { + // 서명 불일치 또는 형식 문제 + log.warn("JWT 토큰 형식이 잘못되었습니다.", e); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } catch (UnsupportedJwtException e) { + log.warn("지원하지 않는 JWT 토큰이 사용되었습니다.", e); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } catch (IllegalArgumentException e) { + log.warn("JWT 토큰의 값이 비어있습니다.", e); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } } public String getExternalId(String token) { diff --git a/src/main/java/com/diggindie/vote/common/exception/CustomException.java b/src/main/java/com/diggindie/vote/common/exception/CustomException.java new file mode 100644 index 0000000..ae99e3b --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/exception/CustomException.java @@ -0,0 +1,22 @@ +package com.diggindie.vote.common.exception; + +import com.diggindie.vote.common.code.ErrorCode; +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + +} + diff --git a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java index 9a43adb..33b0840 100644 --- a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java +++ b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java @@ -100,6 +100,7 @@ public LogoutResponse logout(HttpServletResponse response, String externalId) { return new LogoutResponse(externalId); } + @Transactional(readOnly = true) public TokenReissueResponse reissue(HttpServletRequest request, HttpServletResponse response) { String refreshToken = extractRefreshTokenFromCookie(request); diff --git a/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java b/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java index fb9fd98..c1dee18 100644 --- a/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java +++ b/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java @@ -39,9 +39,5 @@ public boolean validate(String externalId, String token) { String storedToken = get(externalId); return storedToken != null && storedToken.equals(token); } - - public void rotate(String externalId, String newToken) { - save(externalId, newToken); - } } From d43a3b2141703d51d071b5d0d65880f0881ff91e Mon Sep 17 00:00:00 2001 From: Yoonji Date: Thu, 18 Dec 2025 19:50:26 +0900 Subject: [PATCH 43/52] =?UTF-8?q?feat:=20cors=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20swagger=20prod=20=ED=99=98=EA=B2=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/SecurityConfig.java | 50 +++++++++++++------ .../common/config/swagger/SwaggerConfig.java | 5 +- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java index de96f02..c7fa9c5 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java +++ b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java @@ -1,10 +1,8 @@ package com.diggindie.vote.common.config.security; - import com.diggindie.vote.common.config.security.jwt.JwtAccessDeniedHandler; import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationEntryPoint; import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationFilter; -import com.diggindie.vote.common.config.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -16,6 +14,11 @@ 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 @RequiredArgsConstructor @@ -33,27 +36,44 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a @Bean public SecurityFilterChain securityFilterChain( - HttpSecurity http, - JwtTokenProvider jwtTokenProvider - + HttpSecurity http ) throws Exception { http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ) - .exceptionHandling(ex -> ex - .authenticationEntryPoint(authenticationEntryPoint) - .accessDeniedHandler(accessDeniedHandler) - ) - .addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(List.of( + "http://localhost:3000", + "https://diggindie.com" // TODO: 배포 주소로 수정하기 + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java index 67a7cfe..6040944 100644 --- a/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java @@ -16,9 +16,8 @@ description = "Voting service for CEOS 22nd" ), servers = { - @Server(url = "http://localhost:8080", description = "local") - // 배포 서버 있으면 추가 - // @Server(url = "https://api.yourdomain.com", description = "prod") + @Server(url = "http://localhost:8080", description = "local"), + @Server(url = "https://api.diggindie.com", description = "prod") }, security = @SecurityRequirement(name = "bearerAuth") ) From 4d4650c65bf72d475fd29da92e4927dd3babb2a9 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Thu, 18 Dec 2025 20:05:14 +0900 Subject: [PATCH 44/52] =?UTF-8?q?[DEPLOY]=20cors=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20swagger=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 * [FEAT] 기본 세팅 (#8) * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 --------- Co-authored-by: Yoonji Lee * feat : 로그인 관련 API 구현 * feat : 팀 목록 반환 API 개발 * feat : 팀 토표 결과 반환 API 개발 * feat : 파트장 관련 API 개발 * fix : 코드 리뷰 반영 * fix : 코드 리뷰 반영 * [FEAT] swagger 세팅 및 동시성 제어 투표 API 개발, closes #14, 16, 18 (#19) * feat: swagger 관련 초기 세팅 * chore: candidate 패키지로 파일 이동 * refactor: 도메인 수정 * feat: 동시성 제어 * bug: externalId로 조회하도록 수정 * feat: 파트장 투표 관련 코드 추가 * feat: 팀 투표 API 개발 * fix: externalId 관련 수정 * fix: 락 기준 문제 수정 * feat: docker-compose 추가 및 배포시 redis 설정 추가 * fix: 로컬 개발용 docker-compose 수정 * chore: 로컬용 도커 컴포즈 env 파일 사용하도록 수정 * feat : refresh token 로직 작성 * fix: rds 퍼블릭 서브넷으로 변경 * fix: 5432 포트 추가 * fix : 코드 리뷰 반영 * feat: cors 설정 및 swagger prod 환경 추가 --------- Co-authored-by: Hoyoung027 --- infra/modules/vpc/main.tf | 8 +++ .../diggindie/vote/common/code/ErrorCode.java | 6 ++- .../config/security/SecurityConfig.java | 50 +++++++++++++------ .../config/security/jwt/JwtTokenProvider.java | 30 ++++++++--- .../common/config/swagger/SwaggerConfig.java | 2 +- .../common/exception/CustomException.java | 22 ++++++++ .../member/controller/AuthController.java | 17 +++++++ .../member/dto/TokenReissueResponse.java | 8 +++ .../domain/member/service/AuthService.java | 48 ++++++++++++++++-- .../member/service/RefreshTokenService.java | 43 ++++++++++++++++ 10 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/diggindie/vote/common/exception/CustomException.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java create mode 100644 src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java diff --git a/infra/modules/vpc/main.tf b/infra/modules/vpc/main.tf index 7fbbb56..c36aaa5 100644 --- a/infra/modules/vpc/main.tf +++ b/infra/modules/vpc/main.tf @@ -114,6 +114,14 @@ resource "aws_security_group" "web" { cidr_blocks = ["0.0.0.0/0"] } + ingress { + description = "PostgreSQL" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + # 모든 아웃바운드 허용 egress { from_port = 0 diff --git a/src/main/java/com/diggindie/vote/common/code/ErrorCode.java b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java index 33a6890..9ededba 100644 --- a/src/main/java/com/diggindie/vote/common/code/ErrorCode.java +++ b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java @@ -11,7 +11,11 @@ public enum ErrorCode implements Code{ UNAUTHORIZED_ERROR(401, "Unauthorized Exception"), // 권한 없음 - FORBIDDEN_ERROR(403, "Forbidden Exception"); + FORBIDDEN_ERROR(403, "Forbidden Exception"), + + // JWT / Token errors + EXPIRED_TOKEN(401, "Expired JWT token"), + INVALID_TOKEN(401, "Invalid JWT token"); private final int statusCode; private final String message; diff --git a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java index de96f02..c7fa9c5 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java +++ b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java @@ -1,10 +1,8 @@ package com.diggindie.vote.common.config.security; - import com.diggindie.vote.common.config.security.jwt.JwtAccessDeniedHandler; import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationEntryPoint; import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationFilter; -import com.diggindie.vote.common.config.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -16,6 +14,11 @@ 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 @RequiredArgsConstructor @@ -33,27 +36,44 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a @Bean public SecurityFilterChain securityFilterChain( - HttpSecurity http, - JwtTokenProvider jwtTokenProvider - + HttpSecurity http ) throws Exception { http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ) - .exceptionHandling(ex -> ex - .authenticationEntryPoint(authenticationEntryPoint) - .accessDeniedHandler(accessDeniedHandler) - ) - .addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(List.of( + "http://localhost:3000", + "https://diggindie.com" // TODO: 배포 주소로 수정하기 + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java index 4c00360..fda5ad3 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java @@ -3,6 +3,8 @@ import com.diggindie.vote.common.config.security.CustomUserDetailService; import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.exception.CustomException; import com.diggindie.vote.common.enums.Role; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; @@ -90,13 +92,27 @@ public String getAccessToken(HttpServletRequest request) { public Claims parseClaims(String token) { - Claims claims = Jwts.parser() - .verifyWith((SecretKey)key) - .build() - .parseSignedClaims(token) - .getPayload(); - - return claims; + try { + return Jwts.parser() + .verifyWith((SecretKey)key) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + // 만료된 토큰 + log.warn("만료된 JWT 토큰이 사용되었습니다.", e); + throw new CustomException(ErrorCode.EXPIRED_TOKEN); + } catch (SecurityException | MalformedJwtException e) { + // 서명 불일치 또는 형식 문제 + log.warn("JWT 토큰 형식이 잘못되었습니다.", e); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } catch (UnsupportedJwtException e) { + log.warn("지원하지 않는 JWT 토큰이 사용되었습니다.", e); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } catch (IllegalArgumentException e) { + log.warn("JWT 토큰의 값이 비어있습니다.", e); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } } public String getExternalId(String token) { diff --git a/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java index b49a7ea..6040944 100644 --- a/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java @@ -17,7 +17,7 @@ ), servers = { @Server(url = "http://localhost:8080", description = "local"), - @Server(url = "http://13.124.225.185:8080", description = "prod") + @Server(url = "https://api.diggindie.com", description = "prod") }, security = @SecurityRequirement(name = "bearerAuth") ) diff --git a/src/main/java/com/diggindie/vote/common/exception/CustomException.java b/src/main/java/com/diggindie/vote/common/exception/CustomException.java new file mode 100644 index 0000000..ae99e3b --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/exception/CustomException.java @@ -0,0 +1,22 @@ +package com.diggindie.vote.common.exception; + +import com.diggindie.vote.common.code.ErrorCode; +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + +} + diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java index 86cbd99..23071cd 100644 --- a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java +++ b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java @@ -9,7 +9,9 @@ import com.diggindie.vote.domain.member.dto.LogoutResponse; import com.diggindie.vote.domain.member.dto.SignupRequest; import com.diggindie.vote.domain.member.dto.SignupResponse; +import com.diggindie.vote.domain.member.dto.TokenReissueResponse; import com.diggindie.vote.domain.member.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -69,4 +71,19 @@ public ResponseEntity> logout( return ResponseEntity.ok().body(response); } + @PostMapping("/auth/reissue") + public ResponseEntity> reissue( + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "토큰 재발급 API", + authService.reissue(httpRequest, httpResponse) + ); + return ResponseEntity.ok().body(response); + } + } diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java new file mode 100644 index 0000000..8dacab7 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.member.dto; + +public record TokenReissueResponse( + String accessToken, + Long expiresIn +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java index a05e065..33b0840 100644 --- a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java +++ b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java @@ -7,6 +7,8 @@ import com.diggindie.vote.domain.member.dto.*; import com.diggindie.vote.domain.member.repository.MemberRepository; import com.diggindie.vote.domain.team.repository.TeamRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -25,6 +27,7 @@ public class AuthService { private final TeamRepository teamRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; @Value("${jwt.access-token-validity}") private Duration accessTokenValidity; @@ -43,7 +46,7 @@ public LoginResponse login(LoginRequest request, HttpServletResponse response) { } String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); - setCookies(response, member.getExternalId(), member.getRole()); + setRefreshToken(response, member.getExternalId(), member.getRole()); return new LoginResponse( @@ -78,7 +81,7 @@ public SignupResponse signup(SignupRequest request, HttpServletResponse response Member savedMember = memberRepository.save(member); String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); - setCookies(response, savedMember.getExternalId(), savedMember.getRole()); + setRefreshToken(response, savedMember.getExternalId(), savedMember.getRole()); return new SignupResponse( savedMember.getExternalId(), @@ -92,11 +95,47 @@ public SignupResponse signup(SignupRequest request, HttpServletResponse response @Transactional(readOnly = true) public LogoutResponse logout(HttpServletResponse response, String externalId) { - + refreshTokenService.delete(externalId); removeRefreshTokenCookie(response); return new LogoutResponse(externalId); } + @Transactional(readOnly = true) + public TokenReissueResponse reissue(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = extractRefreshTokenFromCookie(request); + + if (refreshToken == null) { + throw new IllegalArgumentException("Refresh token이 존재하지 않습니다."); + } + + String externalId = jwtTokenProvider.parseClaims(refreshToken).getSubject(); + + if (!refreshTokenService.validate(externalId, refreshToken)) { + refreshTokenService.delete(externalId); + removeRefreshTokenCookie(response); + throw new IllegalArgumentException("재로그인이 필요합니다."); + } + + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + String newAccessToken = jwtTokenProvider.generateAccessToken(externalId, member.getRole()); + setRefreshToken(response, externalId, member.getRole()); + + return new TokenReissueResponse(newAccessToken, accessTokenValidity.getSeconds()); + } + + private String extractRefreshTokenFromCookie(HttpServletRequest request) { + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + private void removeRefreshTokenCookie(HttpServletResponse response) { ResponseCookie cookie = ResponseCookie.from("refreshToken", "") .httpOnly(true) @@ -109,8 +148,9 @@ private void removeRefreshTokenCookie(HttpServletResponse response) { response.addHeader("Set-Cookie", cookie.toString()); } - private void setCookies(HttpServletResponse response, String externalId, Role role) { + private void setRefreshToken(HttpServletResponse response, String externalId, Role role) { String refreshToken = jwtTokenProvider.generateRefreshToken(externalId, role); + refreshTokenService.save(externalId, refreshToken); addTokenCookie(response, "refreshToken", refreshToken, refreshTokenValidity); } diff --git a/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java b/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java new file mode 100644 index 0000000..c1dee18 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java @@ -0,0 +1,43 @@ +package com.diggindie.vote.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RedissonClient redissonClient; + + private static final String KEY_PREFIX = "refresh_token:"; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + public void save(String externalId, String token) { + RBucket bucket = redissonClient.getBucket(KEY_PREFIX + externalId); + bucket.set(token, refreshTokenValidity.toMillis(), TimeUnit.MILLISECONDS); + } + + public String get(String externalId) { + RBucket bucket = redissonClient.getBucket(KEY_PREFIX + externalId); + return bucket.get(); + } + + public void delete(String externalId) { + RBucket bucket = redissonClient.getBucket(KEY_PREFIX + externalId); + bucket.delete(); + } + + public boolean validate(String externalId, String token) { + String storedToken = get(externalId); + return storedToken != null && storedToken.equals(token); + } +} + From e8ad38688ae7fee60f50c5264b0091b27d23ad6e Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Thu, 18 Dec 2025 20:15:37 +0900 Subject: [PATCH 45/52] =?UTF-8?q?feat=20:=20swagger=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../candidate/controller/CandidateController.java | 12 ++++++++++-- .../domain/member/controller/AuthController.java | 7 +++++++ .../vote/domain/team/controller/TeamController.java | 6 +++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java index 7daf3f3..fd6f15c 100644 --- a/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java @@ -8,6 +8,9 @@ import com.diggindie.vote.domain.candidate.dto.CandidateListResponse; import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; import com.diggindie.vote.domain.candidate.service.CandidateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -15,6 +18,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "Candidate", description = "파트장 후보 관련 API") @Slf4j @RestController @RequiredArgsConstructor @@ -22,10 +26,11 @@ public class CandidateController { private final CandidateService candidateService; + @Operation(summary = "파트장 후보 목록 조회", description = "특정 파트의 파트장 후보 목록을 조회합니다.") @PreAuthorize("isAuthenticated()") @GetMapping("/candidates") public ResponseEntity> getCandidates( - @RequestParam("part") Part part + @Parameter(description = "파트 (FRONTEND 또는 BACKEND)") @RequestParam("part") Part part ) { return ResponseEntity.ok().body(Response.of( SuccessCode.GET_SUCCESS, @@ -35,10 +40,11 @@ public ResponseEntity> getCandidates( )); } + @Operation(summary = "파트장 투표 결과 조회", description = "특정 파트의 파트장 후보별 득표수를 조회합니다.") @PreAuthorize("isAuthenticated()") @GetMapping("/votes/leaders/results") public ResponseEntity> getCandidatesVote( - @RequestParam("part") Part part + @Parameter(description = "파트 (FRONTEND 또는 BACKEND)") @RequestParam("part") Part part ) { return ResponseEntity.ok().body(Response.of( SuccessCode.GET_SUCCESS, @@ -48,6 +54,7 @@ public ResponseEntity> getCandidatesVote( )); } + @Operation(summary = "파트장 후보 등록", description = "로그인한 사용자를 자신의 파트에 맞는 파트장 후보로 등록합니다.") @PreAuthorize("isAuthenticated()") @PostMapping("/candidates/apply") public ResponseEntity> applyCandidate( @@ -61,6 +68,7 @@ public ResponseEntity> applyCandidate( )); } + @Operation(summary = "파트장 투표", description = "특정 파트장 후보에게 투표합니다.") @PostMapping("/votes/leaders") public ResponseEntity> voteCandidate( @AuthenticationPrincipal CustomUserDetails userDetails, diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java index 23071cd..a5492b1 100644 --- a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java +++ b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java @@ -11,6 +11,8 @@ import com.diggindie.vote.domain.member.dto.SignupResponse; import com.diggindie.vote.domain.member.dto.TokenReissueResponse; import com.diggindie.vote.domain.member.service.AuthService; +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 lombok.RequiredArgsConstructor; @@ -19,12 +21,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "Auth", description = "인증 관련 API") @RestController @RequiredArgsConstructor public class AuthController { private final AuthService authService; + @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다. 가입 성공 시 자동 로그인되어 토큰이 발급됩니다.") @PostMapping("/auth/signup") public ResponseEntity> signup( @RequestBody SignupRequest signupRequest, @@ -40,6 +44,7 @@ public ResponseEntity> signup( return ResponseEntity.ok().body(response); } + @Operation(summary = "로그인", description = "아이디와 비밀번호로 로그인합니다. Access Token은 응답 바디에, Refresh Token은 쿠키에 설정됩니다.") @PostMapping("/auth/login") public ResponseEntity> login( @RequestBody LoginRequest loginRequest, @@ -55,6 +60,7 @@ public ResponseEntity> login( return ResponseEntity.ok().body(response); } + @Operation(summary = "로그아웃", description = "로그아웃합니다. Refresh Token 쿠키가 제거됩니다. 인증된 사용자만 접근 가능합니다.") @PreAuthorize("isAuthenticated()") @PostMapping("/auth/logout") public ResponseEntity> logout( @@ -71,6 +77,7 @@ public ResponseEntity> logout( return ResponseEntity.ok().body(response); } + @Operation(summary = "토큰 재발급", description = "Refresh Token을 이용해 새로운 Access Token과 Refresh Token을 발급받습니다.") @PostMapping("/auth/reissue") public ResponseEntity> reissue( HttpServletRequest httpRequest, diff --git a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java index bf9fbfa..d43e4b5 100644 --- a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java +++ b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java @@ -7,6 +7,8 @@ import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; import com.diggindie.vote.domain.team.service.TeamService; import com.diggindie.vote.domain.team.service.TeamVoteExecutor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RLock; @@ -21,6 +23,7 @@ import java.util.concurrent.TimeUnit; +@Tag(name = "Team", description = "팀 관련 API") @RestController @Slf4j @RequiredArgsConstructor @@ -28,6 +31,7 @@ public class TeamController { private final TeamService teamService; + @Operation(summary = "팀 목록 조회", description = "모든 팀의 목록을 조회합니다.") @PreAuthorize("isAuthenticated()") @GetMapping("/teams") public ResponseEntity> getTeamList() { @@ -41,6 +45,7 @@ public ResponseEntity> getTeamList() { return ResponseEntity.ok().body(response); } + @Operation(summary = "팀 투표", description = "특정 팀에 투표합니다. 자신이 소속된 팀에는 투표할 수 없습니다.") @PreAuthorize("isAuthenticated()") @PostMapping("/votes/teams") public ResponseEntity> voteTeam( @@ -56,4 +61,3 @@ public ResponseEntity> voteTeam( )); } } - From 0943d24cf10d56656fda1392e23bbbbf2d534ca3 Mon Sep 17 00:00:00 2001 From: Yoonji Date: Thu, 18 Dec 2025 20:21:12 +0900 Subject: [PATCH 46/52] =?UTF-8?q?docs:=20vercel=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vote/common/config/security/SecurityConfig.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java index c7fa9c5..e8b04c1 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java +++ b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java @@ -59,8 +59,12 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of( + "https://api.diggindie.com", + "https://diggindie.com", + "https://www.diggindie.com", "http://localhost:3000", - "https://diggindie.com" // TODO: 배포 주소로 수정하기 + "http://localhost:5173", + "https://next-vote-22nd.vercel.app/" )); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); From c370ee04c4cda57557b3b595bd3a815123a5a1b3 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Thu, 18 Dec 2025 20:24:11 +0900 Subject: [PATCH 47/52] =?UTF-8?q?[DEPLOY]=20=EB=B0=B0=ED=8F=AC=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 * [FEAT] 기본 세팅 (#8) * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 --------- Co-authored-by: Yoonji Lee * feat : 로그인 관련 API 구현 * feat : 팀 목록 반환 API 개발 * feat : 팀 토표 결과 반환 API 개발 * feat : 파트장 관련 API 개발 * fix : 코드 리뷰 반영 * fix : 코드 리뷰 반영 * [FEAT] swagger 세팅 및 동시성 제어 투표 API 개발, closes #14, 16, 18 (#19) * feat: swagger 관련 초기 세팅 * chore: candidate 패키지로 파일 이동 * refactor: 도메인 수정 * feat: 동시성 제어 * bug: externalId로 조회하도록 수정 * feat: 파트장 투표 관련 코드 추가 * feat: 팀 투표 API 개발 * fix: externalId 관련 수정 * fix: 락 기준 문제 수정 * feat: docker-compose 추가 및 배포시 redis 설정 추가 * fix: 로컬 개발용 docker-compose 수정 * chore: 로컬용 도커 컴포즈 env 파일 사용하도록 수정 * feat : refresh token 로직 작성 * fix: rds 퍼블릭 서브넷으로 변경 * fix: 5432 포트 추가 * fix : 코드 리뷰 반영 * feat: cors 설정 및 swagger prod 환경 추가 * feat : swagger 세팅 * docs: vercel 관련 주소 추가 --------- Co-authored-by: Hoyoung027 --- .../vote/common/config/security/SecurityConfig.java | 6 +++++- .../candidate/controller/CandidateController.java | 12 ++++++++++-- .../domain/member/controller/AuthController.java | 7 +++++++ .../vote/domain/team/controller/TeamController.java | 6 +++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java index c7fa9c5..e8b04c1 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java +++ b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java @@ -59,8 +59,12 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of( + "https://api.diggindie.com", + "https://diggindie.com", + "https://www.diggindie.com", "http://localhost:3000", - "https://diggindie.com" // TODO: 배포 주소로 수정하기 + "http://localhost:5173", + "https://next-vote-22nd.vercel.app/" )); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); diff --git a/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java index 7daf3f3..fd6f15c 100644 --- a/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java +++ b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java @@ -8,6 +8,9 @@ import com.diggindie.vote.domain.candidate.dto.CandidateListResponse; import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; import com.diggindie.vote.domain.candidate.service.CandidateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -15,6 +18,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "Candidate", description = "파트장 후보 관련 API") @Slf4j @RestController @RequiredArgsConstructor @@ -22,10 +26,11 @@ public class CandidateController { private final CandidateService candidateService; + @Operation(summary = "파트장 후보 목록 조회", description = "특정 파트의 파트장 후보 목록을 조회합니다.") @PreAuthorize("isAuthenticated()") @GetMapping("/candidates") public ResponseEntity> getCandidates( - @RequestParam("part") Part part + @Parameter(description = "파트 (FRONTEND 또는 BACKEND)") @RequestParam("part") Part part ) { return ResponseEntity.ok().body(Response.of( SuccessCode.GET_SUCCESS, @@ -35,10 +40,11 @@ public ResponseEntity> getCandidates( )); } + @Operation(summary = "파트장 투표 결과 조회", description = "특정 파트의 파트장 후보별 득표수를 조회합니다.") @PreAuthorize("isAuthenticated()") @GetMapping("/votes/leaders/results") public ResponseEntity> getCandidatesVote( - @RequestParam("part") Part part + @Parameter(description = "파트 (FRONTEND 또는 BACKEND)") @RequestParam("part") Part part ) { return ResponseEntity.ok().body(Response.of( SuccessCode.GET_SUCCESS, @@ -48,6 +54,7 @@ public ResponseEntity> getCandidatesVote( )); } + @Operation(summary = "파트장 후보 등록", description = "로그인한 사용자를 자신의 파트에 맞는 파트장 후보로 등록합니다.") @PreAuthorize("isAuthenticated()") @PostMapping("/candidates/apply") public ResponseEntity> applyCandidate( @@ -61,6 +68,7 @@ public ResponseEntity> applyCandidate( )); } + @Operation(summary = "파트장 투표", description = "특정 파트장 후보에게 투표합니다.") @PostMapping("/votes/leaders") public ResponseEntity> voteCandidate( @AuthenticationPrincipal CustomUserDetails userDetails, diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java index 23071cd..a5492b1 100644 --- a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java +++ b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java @@ -11,6 +11,8 @@ import com.diggindie.vote.domain.member.dto.SignupResponse; import com.diggindie.vote.domain.member.dto.TokenReissueResponse; import com.diggindie.vote.domain.member.service.AuthService; +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 lombok.RequiredArgsConstructor; @@ -19,12 +21,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "Auth", description = "인증 관련 API") @RestController @RequiredArgsConstructor public class AuthController { private final AuthService authService; + @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다. 가입 성공 시 자동 로그인되어 토큰이 발급됩니다.") @PostMapping("/auth/signup") public ResponseEntity> signup( @RequestBody SignupRequest signupRequest, @@ -40,6 +44,7 @@ public ResponseEntity> signup( return ResponseEntity.ok().body(response); } + @Operation(summary = "로그인", description = "아이디와 비밀번호로 로그인합니다. Access Token은 응답 바디에, Refresh Token은 쿠키에 설정됩니다.") @PostMapping("/auth/login") public ResponseEntity> login( @RequestBody LoginRequest loginRequest, @@ -55,6 +60,7 @@ public ResponseEntity> login( return ResponseEntity.ok().body(response); } + @Operation(summary = "로그아웃", description = "로그아웃합니다. Refresh Token 쿠키가 제거됩니다. 인증된 사용자만 접근 가능합니다.") @PreAuthorize("isAuthenticated()") @PostMapping("/auth/logout") public ResponseEntity> logout( @@ -71,6 +77,7 @@ public ResponseEntity> logout( return ResponseEntity.ok().body(response); } + @Operation(summary = "토큰 재발급", description = "Refresh Token을 이용해 새로운 Access Token과 Refresh Token을 발급받습니다.") @PostMapping("/auth/reissue") public ResponseEntity> reissue( HttpServletRequest httpRequest, diff --git a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java index bf9fbfa..d43e4b5 100644 --- a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java +++ b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java @@ -7,6 +7,8 @@ import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; import com.diggindie.vote.domain.team.service.TeamService; import com.diggindie.vote.domain.team.service.TeamVoteExecutor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RLock; @@ -21,6 +23,7 @@ import java.util.concurrent.TimeUnit; +@Tag(name = "Team", description = "팀 관련 API") @RestController @Slf4j @RequiredArgsConstructor @@ -28,6 +31,7 @@ public class TeamController { private final TeamService teamService; + @Operation(summary = "팀 목록 조회", description = "모든 팀의 목록을 조회합니다.") @PreAuthorize("isAuthenticated()") @GetMapping("/teams") public ResponseEntity> getTeamList() { @@ -41,6 +45,7 @@ public ResponseEntity> getTeamList() { return ResponseEntity.ok().body(response); } + @Operation(summary = "팀 투표", description = "특정 팀에 투표합니다. 자신이 소속된 팀에는 투표할 수 없습니다.") @PreAuthorize("isAuthenticated()") @PostMapping("/votes/teams") public ResponseEntity> voteTeam( @@ -56,4 +61,3 @@ public ResponseEntity> voteTeam( )); } } - From 23e7ab2dda683ee18ae9a3a0ed501e5b8493d2ea Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Fri, 19 Dec 2025 22:19:02 +0900 Subject: [PATCH 48/52] =?UTF-8?q?feat:=20=ED=8C=80=20=ED=88=AC=ED=91=9C=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/controller/TeamController.java | 21 +++++++++++++------ .../domain/team/dto/TeamVoteResultDto.java | 9 ++++++++ .../team/dto/TeamVoteResultResponse.java | 8 +++++++ .../team/repository/TeamRepository.java | 5 ++--- .../vote/domain/team/service/TeamService.java | 20 ++++++++++++++---- src/main/resources/application.yml | 2 +- 6 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java diff --git a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java index d43e4b5..e3a7edf 100644 --- a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java +++ b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java @@ -5,14 +5,12 @@ import com.diggindie.vote.common.response.Response; import com.diggindie.vote.domain.team.dto.TeamListResponse; import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.dto.TeamVoteResultResponse; import com.diggindie.vote.domain.team.service.TeamService; -import com.diggindie.vote.domain.team.service.TeamVoteExecutor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -21,8 +19,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import java.util.concurrent.TimeUnit; - @Tag(name = "Team", description = "팀 관련 API") @RestController @Slf4j @@ -60,4 +56,17 @@ public ResponseEntity> voteTeam( (Void) null )); } -} + + @PreAuthorize("isAuthenticated()") + @GetMapping("/votes/teams") + public ResponseEntity> getTeamVoteResults() { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "팀 투표 결과 반환 API", + teamService.getTeamVoteResults() + ); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java new file mode 100644 index 0000000..533ca47 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.team.dto; + +public record TeamVoteResultDto( + Long teamId, + String teamName, + String teamProposal, + Long currentVote +) { +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java new file mode 100644 index 0000000..a15555b --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.team.dto; + +import com.diggindie.vote.domain.team.dto.TeamVoteResultDto; +import java.util.List; + +public record TeamVoteResultResponse( + List teamVoteResults +) {} diff --git a/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java index c1a16cc..7231300 100644 --- a/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java +++ b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java @@ -1,6 +1,5 @@ package com.diggindie.vote.domain.team.repository; -import com.diggindie.vote.domain.member.domain.Member; import com.diggindie.vote.domain.team.domain.Team; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,5 +8,5 @@ public interface TeamRepository extends JpaRepository { Optional findByTeamName(String teamName); - -} + +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java index f8d92d8..0d7ad55 100644 --- a/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java +++ b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java @@ -1,9 +1,7 @@ package com.diggindie.vote.domain.team.service; import com.diggindie.vote.domain.team.domain.Team; -import com.diggindie.vote.domain.team.dto.TeamDto; -import com.diggindie.vote.domain.team.dto.TeamListResponse; -import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.dto.*; import com.diggindie.vote.domain.team.repository.TeamRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,7 +11,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -61,5 +61,17 @@ public void vote(String externalId, TeamVoteRequestDto request) { if (lock.isHeldByCurrentThread()) lock.unlock(); } } -} + public TeamVoteResultResponse getTeamVoteResults() { + List results = teamRepository.findAll().stream() + .map(team -> new TeamVoteResultDto( + team.getId(), + team.getTeamName(), + team.getProposal(), + team.getVoteCount() == null ? 0L : team.getVoteCount().longValue() + )) + .toList(); + + return new TeamVoteResultResponse(results); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index dcdeec7..e553102 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: jpa: hibernate: - ddl-auto: ${DDL_AUTO:update} + ddl-auto: ${DDL_AUTO:validate} show-sql: true properties: hibernate: From 1c4723eebe24643379e3b4d78e77b8a79760cbab Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Fri, 19 Dec 2025 22:23:06 +0900 Subject: [PATCH 49/52] =?UTF-8?q?[DEPLOY]=20=EB=B0=B0=ED=8F=AC=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 * [FEAT] 기본 세팅 (#8) * feat : 의존성 구성 * feat : 기본 응답 구조 구현 * feat : security 설정 --------- Co-authored-by: Yoonji Lee * feat : 로그인 관련 API 구현 * feat : 팀 목록 반환 API 개발 * feat : 팀 토표 결과 반환 API 개발 * feat : 파트장 관련 API 개발 * fix : 코드 리뷰 반영 * fix : 코드 리뷰 반영 * [FEAT] swagger 세팅 및 동시성 제어 투표 API 개발, closes #14, 16, 18 (#19) * feat: swagger 관련 초기 세팅 * chore: candidate 패키지로 파일 이동 * refactor: 도메인 수정 * feat: 동시성 제어 * bug: externalId로 조회하도록 수정 * feat: 파트장 투표 관련 코드 추가 * feat: 팀 투표 API 개발 * fix: externalId 관련 수정 * fix: 락 기준 문제 수정 * feat: docker-compose 추가 및 배포시 redis 설정 추가 * fix: 로컬 개발용 docker-compose 수정 * chore: 로컬용 도커 컴포즈 env 파일 사용하도록 수정 * feat : refresh token 로직 작성 * fix: rds 퍼블릭 서브넷으로 변경 * fix: 5432 포트 추가 * fix : 코드 리뷰 반영 * feat: cors 설정 및 swagger prod 환경 추가 * feat : swagger 세팅 * docs: vercel 관련 주소 추가 * feat: 팀 투표 결과 조회 기능 --------- Co-authored-by: Hoyoung027 --- .../team/controller/TeamController.java | 21 ++++++++++++++++--- .../domain/team/dto/TeamVoteResultDto.java | 9 ++++++++ .../team/dto/TeamVoteResultResponse.java | 8 +++++++ .../team/repository/TeamRepository.java | 5 ++--- .../vote/domain/team/service/TeamService.java | 20 ++++++++++++++---- src/main/resources/application.yml | 2 +- 6 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java create mode 100644 src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java diff --git a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java index d43e4b5..ff6272b 100644 --- a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java +++ b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java @@ -5,14 +5,15 @@ import com.diggindie.vote.common.response.Response; import com.diggindie.vote.domain.team.dto.TeamListResponse; import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.dto.TeamVoteResultResponse; import com.diggindie.vote.domain.team.service.TeamService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import com.diggindie.vote.domain.team.service.TeamVoteExecutor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -60,4 +61,18 @@ public ResponseEntity> voteTeam( (Void) null )); } -} + + @Operation(summary = "팀 투표 결과 조회", description = "팀 투표 결과 조회") + @PreAuthorize("isAuthenticated()") + @GetMapping("/votes/teams/results") + public ResponseEntity> getTeamVoteResults() { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "팀 투표 결과 반환 API", + teamService.getTeamVoteResults() + ); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java new file mode 100644 index 0000000..533ca47 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.team.dto; + +public record TeamVoteResultDto( + Long teamId, + String teamName, + String teamProposal, + Long currentVote +) { +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java new file mode 100644 index 0000000..a15555b --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.team.dto; + +import com.diggindie.vote.domain.team.dto.TeamVoteResultDto; +import java.util.List; + +public record TeamVoteResultResponse( + List teamVoteResults +) {} diff --git a/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java index c1a16cc..7231300 100644 --- a/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java +++ b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java @@ -1,6 +1,5 @@ package com.diggindie.vote.domain.team.repository; -import com.diggindie.vote.domain.member.domain.Member; import com.diggindie.vote.domain.team.domain.Team; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,5 +8,5 @@ public interface TeamRepository extends JpaRepository { Optional findByTeamName(String teamName); - -} + +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java index f8d92d8..0d7ad55 100644 --- a/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java +++ b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java @@ -1,9 +1,7 @@ package com.diggindie.vote.domain.team.service; import com.diggindie.vote.domain.team.domain.Team; -import com.diggindie.vote.domain.team.dto.TeamDto; -import com.diggindie.vote.domain.team.dto.TeamListResponse; -import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.dto.*; import com.diggindie.vote.domain.team.repository.TeamRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,7 +11,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -61,5 +61,17 @@ public void vote(String externalId, TeamVoteRequestDto request) { if (lock.isHeldByCurrentThread()) lock.unlock(); } } -} + public TeamVoteResultResponse getTeamVoteResults() { + List results = teamRepository.findAll().stream() + .map(team -> new TeamVoteResultDto( + team.getId(), + team.getTeamName(), + team.getProposal(), + team.getVoteCount() == null ? 0L : team.getVoteCount().longValue() + )) + .toList(); + + return new TeamVoteResultResponse(results); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index dcdeec7..e553102 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: jpa: hibernate: - ddl-auto: ${DDL_AUTO:update} + ddl-auto: ${DDL_AUTO:validate} show-sql: true properties: hibernate: From b66622c0c89859d84c79b9977010aa3a9b5f51cc Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Fri, 19 Dec 2025 22:38:43 +0900 Subject: [PATCH 50/52] Change ddl-auto setting from validate to update --- src/main/resources/application.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e553102..fa17225 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: jpa: hibernate: - ddl-auto: ${DDL_AUTO:validate} + ddl-auto: ${DDL_AUTO:update} show-sql: true properties: hibernate: @@ -34,4 +34,4 @@ logging: jwt: secret-key: ${JWT_SECRET_KEY:diggindievotingserviceeeeeeeeeeeee1234567890} access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} - refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} \ No newline at end of file + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} From bdc8aa72791a2bcd05a9228d38c4558ea38bc6b6 Mon Sep 17 00:00:00 2001 From: Hoyoung027 Date: Sun, 21 Dec 2025 00:45:14 +0900 Subject: [PATCH 51/52] =?UTF-8?q?fix=20:=20cors=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../diggindie/vote/common/config/security/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java index e8b04c1..f93a739 100644 --- a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java +++ b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java @@ -64,7 +64,7 @@ public CorsConfigurationSource corsConfigurationSource() { "https://www.diggindie.com", "http://localhost:3000", "http://localhost:5173", - "https://next-vote-22nd.vercel.app/" + "https://next-vote-22nd-eomg.vercel.app/" )); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); From 583a4c208bca44bb09c41225711ffd5252172677 Mon Sep 17 00:00:00 2001 From: Yoonji Lee Date: Mon, 22 Dec 2025 18:00:03 +0900 Subject: [PATCH 52/52] docs: Update README with mid-term presentation link Added a link to the mid-term presentation materials. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7f5d396..74a0691 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # spring-vote-22nd ceos back-end 22nd voting service project + +[🚀 중간과제 발표 자료](https://www.figma.com/proto/3dN8Wnp3DyfXGTRm0ULMRF/CEOS-%EB%94%94%EA%B8%B4%EB%94%94?page-id=1813%3A15434&node-id=1962-2108&viewport=805%2C211%2C0.11&t=6ZI3P9hnxkRAE4bh-1&scaling=contain&content-scaling=fixed&starting-point-node-id=1962%3A2108)