diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/ISSUE_TEMPLATE/bug_issue_template.md b/.github/ISSUE_TEMPLATE/bug_issue_template.md new file mode 100644 index 0000000..280bf45 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_issue_template.md @@ -0,0 +1,14 @@ +--- +name: ๐Ÿž Bug +about: ๋ฒ„๊ทธ ์ œ๋ณด +title: '' +assignees: '' +--- + +### โœจ ๋ฒ„๊ทธ ์„ค๋ช… + + + +### โœจ ์ฐธ๊ณ  ์ž๋ฃŒ + + diff --git a/.github/ISSUE_TEMPLATE/empty_issue_template.md b/.github/ISSUE_TEMPLATE/empty_issue_template.md new file mode 100644 index 0000000..cb305d2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/empty_issue_template.md @@ -0,0 +1,7 @@ +--- +name: ๐Ÿ’ฌ Empty Issue +about: ๊ทธ๋ƒฅ ๋นˆ ์ด์Šˆ ํ…œํ”Œ๋ฆฟ +title: '' +assignees: '' +--- + diff --git a/.github/ISSUE_TEMPLATE/general_issue_template.md b/.github/ISSUE_TEMPLATE/general_issue_template.md new file mode 100644 index 0000000..270757e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/general_issue_template.md @@ -0,0 +1,16 @@ +--- +name: โœจ Issue +about: ์ผ๋ฐ˜ ์ด์Šˆ ํ…œํ”Œ๋ฆฟ +title: '' +assignees: '' +--- + +### โœจ ์ด์Šˆ ์„ค๋ช… + + + + + +### โœจ ์ž‘์—… ์˜ˆ์ƒ ์‹œ๊ฐ„ + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..657209f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ + + + +## #๏ธโƒฃ ์—ฐ๊ด€ ์ด์Šˆ + +> Closes #n + +## ๐Ÿ“ ์š”์•ฝ + + +- ์ž‘์—… ๋‚ด์šฉ ์š”์•ฝ1 +- ์ž‘์—… ๋‚ด์šฉ ์š”์•ฝ2 + +## ๐Ÿ™ ๋ฆฌ๋ทฐ ์š”์ฒญ์‚ฌํ•ญ + + + +## ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ƒ์„ธ + +### ์ž‘์—… ๋‚ด์šฉ ์š”์•ฝ1 +- ์ž‘์—… ๋‚ด์šฉ ์„ค๋ช… +- ์ž‘์—… ๋‚ด์šฉ ์„ค๋ช… +### ์ž‘์—… ๋‚ด์šฉ ์š”์•ฝ2 +- ์ž‘์—… ๋‚ด์šฉ ์„ค๋ช… +- ์ž‘์—… ๋‚ด์šฉ ์„ค๋ช… + diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml new file mode 100644 index 0000000..6c72c74 --- /dev/null +++ b/.github/workflows/dev_deploy.yml @@ -0,0 +1,106 @@ +name: idedu backend CI/DE + +on: + push: + branches: + - develop + +permissions: + contents: read + +jobs: + build: + name: Build in Github Actions + runs-on: ubuntu-22.04 + + steps: + # ์ €์žฅ์†Œ Checkoutํ•˜์—ฌ ์ฝ”๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ + - name: Checkout branch + uses: actions/checkout@v3 + + # ์ตœ์‹  ์ปค๋ฐ‹ ์ถœ๋ ฅํ•˜๊ธฐ (ํ™•์ธ์šฉ) + - name: Show latest commit + run: | + latest_commit=$(git log -1 --pretty=format:"%h %s (%an)") + echo "Latest commit: $latest_commit" + + # Java ๋ฒ„์ „ ์„ธํŒ… + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'zulu' + + # yml ํŒŒ์ผ ๋ณต์‚ฌ + - name: Copy secret + env: + APPLICATION_FILE: ${{ secrets.APPLICATION_PROFILE }} + APPLICATION_FILE_NAME: application.yml + DIR: ./src/main/resources + run: | + touch $DIR/$APPLICATION_FILE_NAME + echo "$APPLICATION_FILE" > $DIR/$APPLICATION_FILE_NAME + + # gradlew ์‹คํ–‰ ๊ถŒํ•œ ๋ถ€์—ฌ + - name: Run chmod to make gradlew executable + run: chmod +x ./gradlew + shell: bash + + # JAR ํŒŒ์ผ ์ƒ์„ฑ + - name: Build with Gradle + run: ./gradlew clean build -x test + shell: bash + + # jar ๋ฐ ์†Œ์Šค์ฝ”๋“œ ์—…๋กœ๋“œ + - name: Upload Build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + build/libs/*-SNAPSHOT.jar + src/main/java/com/gdg/backend + + deploy: + name: Deliver using SSH + needs: build + runs-on: ubuntu-22.04 + + steps: + # jar ๋ฐ ์†Œ์Šค์ฝ”๋“œ ๋‹ค์šด๋กœ๋“œ + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts + + # SCP๋กœ jar ํŒŒ์ผ EC2์— ๋ฐฐํฌ + - name: SCP JAR to EC2 + uses: appleboy/scp-action@master + with: + key: ${{ secrets.EC2_KEY }} + host: ${{ secrets.EC2_HOST }} + username: ubuntu + source: "build/libs/*.jar" + target: "/home/ubuntu/app" + + # SCP๋กœ ์†Œ์Šค์ฝ”๋“œ EC2์— ๋ถ™์—ฌ๋„ฃ๊ธฐ (ํ™•์ธ์šฉ) + - name: SCP project source code to EC2 + uses: appleboy/scp-action@master + with: + key: ${{ secrets.EC2_KEY }} + host: ${{ secrets.EC2_HOST }} + username: ubuntu + source: "src/" + target: "/home/ubuntu/app" + + # jar ์‹คํ–‰ + - name: Deploy SSH + uses: appleboy/ssh-action@master + with: + key: ${{ secrets.EC2_KEY }} + host: ${{ secrets.EC2_HOST }} + username: ubuntu + # 8080 ํฌํŠธ์—์„œ ์‹คํ–‰ ์ค‘์ธ ์„œ๋ฒ„ ์ข…๋ฃŒ ํ›„ ์žฌ์‹คํ–‰ + script: | + sudo fuser -k -n tcp 8080 + sleep 15 + sudo nohup java -jar -Duser.timezone=Asia/Seoul ./app/build/libs/*.jar > ./nohup.out 2>&1 & diff --git a/.github/workflows/dev_test.yml b/.github/workflows/dev_test.yml new file mode 100644 index 0000000..94ffcde --- /dev/null +++ b/.github/workflows/dev_test.yml @@ -0,0 +1,55 @@ +name: idedu test pr (develop branch) + +on: + pull_request: + branches: + - develop + +permissions: + contents: read + +jobs: + build: + name: Run tests + runs-on: ubuntu-22.04 + + steps: + # ์ž‘์—… ์—‘์„ธ์Šค ๊ฐ€๋Šฅํ•˜๊ฒŒ $GITHUB_WORKSPACE์—์„œ ์ €์žฅ์†Œ๋ฅผ ์ฒดํฌ์•„์›ƒ + - name: Checkout branch + uses: actions/checkout@v3 + + # java ๋ฒ„์ „ ์„ธํŒ…(JDK 17) + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'zulu' + + # yml ํŒŒ์ผ github secret์—์„œ ๋ณต์‚ฌ + - name: Copy secret + env: + APPLICATION_FILE: ${{ secrets.APPLICATION_PROFILE_TEST }} + DIR: ./src/main/resources + + APPLICATION_FILE_NAME: application.yml + run: | + touch $DIR/$APPLICATION_FILE_NAME + echo "$APPLICATION_FILE" > $DIR/$APPLICATION_FILE_NAME + + # github actions cache์—์„œ gradle ์บ์‹œ ๊ธฐ์ ธ์˜ด (์˜์กด์„ฑ & ๋นŒ๋“œ ๋ฐ์ดํ„ฐ) + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-gradle + + # gradlew ์‹คํ–‰ ๊ถŒํ•œ ๋ถ€์—ฌ + - name: Make gradlew executable + run: chmod +x ./gradlew + shell: bash + + # ํ…Œ์ŠคํŠธ ์‹คํ–‰ + - name: Run tests + run: ./gradlew test + shell: bash diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa1333c --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +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/ + +### Custom ### +application*.yml +application*.yaml diff --git a/README.md b/README.md index 86212da..1928177 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,19 @@ -# IDEdu_BE -ํ•™์ƒ๋“ค์„ ์œ„ํ•œ ์ฝ”๋”ฉ ๊ต์œก ํ”Œ๋žซํผ - SDGs ํ”„๋กœ์ ํŠธ 7์กฐ +![idedu-logo](https://github.com/user-attachments/assets/55d99881-6322-4c0c-b509-c1f554791b90) + +>**IDEdu**๋Š” **์˜จ๋ผ์ธ ์ฝ”๋”ฉ ๊ต์œก์˜ ํ•œ๊ณ„์ ์„ ๊ทน๋ณตํ•˜๊ธฐ ์œ„ํ•œ ํด๋ผ์šฐ๋“œ ์ฝ”๋”ฉ ํ”Œ๋žซํผ**์ž…๋‹ˆ๋‹ค. +>๊ต์œก ์ค‘ ๊ต์ˆ˜์ž์™€ ํ•™์ƒ์˜ IDE ์ƒํƒœ๋Š” ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ค‘๊ณ„๋˜์–ด ์„œ๋กœ์˜ ์ฝ”๋“œ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, +>๊ต์ˆ˜์ž๋Š” ํ•™์ƒ์˜ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +>์„œ๋ฒ„๋Š” ๋ชจ๋“  ํ•™์ƒ์˜ ๋นŒ๋“œ ๊ธฐ๋ก์„ ์ €์žฅํ•˜๋ฉฐ, ๊ฐ ํ•™์ƒ์ด ์–ด๋А ๋ถ€๋ถ„์—์„œ ์‹ค์ˆ˜๋ฅผ ํ•˜๋Š”์ง€ ํŒŒ์•…ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + + +### ๋ฐฑ์—”๋“œ ๋‹ค์ด์–ด๊ทธ๋žจ +![idedu-architecture](https://github.com/user-attachments/assets/d4af07e9-b1b4-456a-ab4d-150cde734aad) + + +### ๋ฌธ์„œ ๋™์‹œ ํŽธ์ง‘ +![idedu-documenedit](https://github.com/user-attachments/assets/1224f146-7cac-424f-bf56-fe9b20d1d0b9) + +### ์‚ฌ์šฉ์ž ์ฝ”๋“œ ๋นŒ๋“œ ๋ฐ ์‹คํ–‰ +![idedu-build](https://github.com/user-attachments/assets/feb94927-b835-4dce-9388-35040aab3f4d) + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..9faa480 --- /dev/null +++ b/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.2' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.gdg' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' // swagger + implementation 'org.springframework.boot:spring-boot-starter-websocket' // websocket + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'org.springframework.boot:spring-boot-starter-security' // security + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e18bc25 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.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..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..0f5036d --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'backend' diff --git a/src/main/java/com/gdg/backend/BackendApplication.java b/src/main/java/com/gdg/backend/BackendApplication.java new file mode 100644 index 0000000..2cb3087 --- /dev/null +++ b/src/main/java/com/gdg/backend/BackendApplication.java @@ -0,0 +1,18 @@ +package com.gdg.backend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableJpaAuditing +@EnableScheduling +public class BackendApplication { + + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } + +} diff --git a/src/main/java/com/gdg/backend/common/annotation/AuthUser.java b/src/main/java/com/gdg/backend/common/annotation/AuthUser.java new file mode 100644 index 0000000..03aa833 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/annotation/AuthUser.java @@ -0,0 +1,14 @@ +package com.gdg.backend.common.annotation; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : #this") +public @interface AuthUser { +} \ No newline at end of file diff --git a/src/main/java/com/gdg/backend/common/annotation/TrackExecutionTime.java b/src/main/java/com/gdg/backend/common/annotation/TrackExecutionTime.java new file mode 100644 index 0000000..f63602e --- /dev/null +++ b/src/main/java/com/gdg/backend/common/annotation/TrackExecutionTime.java @@ -0,0 +1,11 @@ +package com.gdg.backend.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) // ๋ฉ”์„œ๋“œ์™€ ํด๋ž˜์Šค์— ๋ถ€์ฐฉ ๊ฐ€๋Šฅ +@Retention(RetentionPolicy.RUNTIME) // ๋Ÿฐํƒ€์ž„์—๋„ ์–ด๋…ธํ…Œ์ด์…˜ ๋‚จ์•„์žˆ๋„๋ก ์„ค์ • +public @interface TrackExecutionTime { +} diff --git a/src/main/java/com/gdg/backend/common/annotation/aspect/ExecutionTimeAspect.java b/src/main/java/com/gdg/backend/common/annotation/aspect/ExecutionTimeAspect.java new file mode 100644 index 0000000..866d656 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/annotation/aspect/ExecutionTimeAspect.java @@ -0,0 +1,30 @@ +package com.gdg.backend.common.annotation.aspect; + + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Aspect +@Slf4j +@Component +public class ExecutionTimeAspect { + + @Around("@annotation(com.gdg.backend.common.annotation.TrackExecutionTime) " + + "|| @within(com.gdg.backend.common.annotation.TrackExecutionTime) " + + "|| execution(* org.springframework.data.jpa.repository.JpaRepository+.*(..))" + // JPA Repository ํฌํ•จ + "|| execution(* org.springframework.messaging.simp.SimpMessagingTemplate.convertAndSend(..))") // SimpMessagingTemplate ํฌํ•จ + public Object trackExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { + // ๋ฉ”์„œ๋“œ ์‹คํ–‰ ์ „ํ›„๋กœ ์‹คํ–‰์‹œ๊ฐ„ ์ธก์ • + long start = System.currentTimeMillis(); + Object proceed = joinPoint.proceed(); // ๊ธฐ์กด ๋ฉ”์„œ๋“œ ์‹คํ–‰ + long end = System.currentTimeMillis(); + + String className = joinPoint.getSignature().getDeclaringType().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + log.info(className + ": " + methodName + "() took " + (end - start) + "ms"); + return proceed; + } +} diff --git a/src/main/java/com/gdg/backend/common/entity/BaseTimeEntity.java b/src/main/java/com/gdg/backend/common/entity/BaseTimeEntity.java new file mode 100644 index 0000000..fabae86 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/entity/BaseTimeEntity.java @@ -0,0 +1,27 @@ +package com.gdg.backend.common.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.web.bind.annotation.RequestParam; + +@MappedSuperclass // ์ด ํด๋ž˜์Šค๋Š” ๋‹ค๋ฅธ ์—”ํ‹ฐํ‹ฐ ํด๋ž˜์Šค์˜ ๋ถ€๋ชจ ํด๋ž˜์Šค๊ฐ€ ๋  ์ˆ˜ ์žˆ๋„๋ก ์„ค์ • +@EntityListeners(AuditingEntityListener.class) // JPA Auditing ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ ์„ค์ • +@Getter +@Setter +public abstract class BaseTimeEntity { + + @CreatedDate // ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ์‹œ ์ž๋™์œผ๋กœ ํ˜„์žฌ ์‹œ๊ฐ„์ด ์ €์žฅ๋จ + private LocalDateTime createdDate; + + @LastModifiedDate // ์—”ํ‹ฐํ‹ฐ ์ˆ˜์ • ์‹œ ์ž๋™์œผ๋กœ ํ˜„์žฌ ์‹œ๊ฐ„์ด ์ €์žฅ๋จ + private LocalDateTime modifiedDate; +} + diff --git a/src/main/java/com/gdg/backend/common/exception/ExceptionAdvice.java b/src/main/java/com/gdg/backend/common/exception/ExceptionAdvice.java new file mode 100644 index 0000000..b70f66c --- /dev/null +++ b/src/main/java/com/gdg/backend/common/exception/ExceptionAdvice.java @@ -0,0 +1,85 @@ +package com.gdg.backend.common.exception; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import com.gdg.backend.common.response.ApiResponse; +import com.gdg.backend.common.response.ErrorReasonDTO; +import com.gdg.backend.common.response.status.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@Slf4j +@RestControllerAdvice(annotations = {RestController.class}) +public class ExceptionAdvice extends ResponseEntityExceptionHandler { + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleValidation(ConstraintViolationException e, WebRequest request) { + String errorMessage = e.getConstraintViolations().stream() + .map(constraintViolation -> constraintViolation.getMessage()) + .findFirst() + .orElseThrow(() -> new RuntimeException("ConstraintViolationException ์ถ”์ถœ ๋„์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ")); + log.error("Constraint violation exception occurred: ", e); + return handleExceptionInternalConstraint(e, ErrorCode.valueOf(errorMessage), HttpHeaders.EMPTY, request); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + Map errors = new LinkedHashMap<>(); + e.getBindingResult().getFieldErrors().forEach(fieldError -> { + String fieldName = fieldError.getField(); + String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); + errors.merge(fieldName, errorMessage, (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); + }); + return handleExceptionInternalArgs(e, HttpHeaders.EMPTY, ErrorCode.valueOf("BAD_REQUEST"), request, errors); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception e, WebRequest request) { + log.error("Unhandled exception occurred: ", e); + return handleExceptionInternalFalse(e, ErrorCode._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, HttpStatus.INTERNAL_SERVER_ERROR, request, e.getMessage()); + } + + @ExceptionHandler(GeneralException.class) + public ResponseEntity handleGeneralException(GeneralException generalException, HttpServletRequest request) { + log.error("General exception occurred: {}", generalException.getCode()); + ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus(); + return handleExceptionInternal(generalException, errorReasonHttpStatus, null, request); + } + + private ResponseEntity handleExceptionInternal(Exception e, ErrorReasonDTO reason, HttpHeaders headers, HttpServletRequest request) { + ApiResponse body = ApiResponse.onFailure(reason.getCode(), reason.getMessage(), null); + WebRequest webRequest = new ServletWebRequest(request); + return super.handleExceptionInternal(e, body, headers, reason.getHttpStatus(), webRequest); + } + + private ResponseEntity handleExceptionInternalFalse(Exception e, ErrorCode errorCommonStatus, HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), errorPoint); + return super.handleExceptionInternal(e, body, headers, status, request); + } + + private ResponseEntity handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorCode errorCommonStatus, WebRequest request, Map errorArgs) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), errorArgs); + return super.handleExceptionInternal(e, body, headers, errorCommonStatus.getHttpStatus(), request); + } + + private ResponseEntity handleExceptionInternalConstraint(Exception e, ErrorCode errorCommonStatus, HttpHeaders headers, WebRequest request) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null); + return super.handleExceptionInternal(e, body, headers, errorCommonStatus.getHttpStatus(), request); + } + +} diff --git a/src/main/java/com/gdg/backend/common/exception/GeneralException.java b/src/main/java/com/gdg/backend/common/exception/GeneralException.java new file mode 100644 index 0000000..b4a48ec --- /dev/null +++ b/src/main/java/com/gdg/backend/common/exception/GeneralException.java @@ -0,0 +1,21 @@ +package com.gdg.backend.common.exception; + +import com.gdg.backend.common.response.BaseErrorCode; +import com.gdg.backend.common.response.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GeneralException extends RuntimeException { + + private BaseErrorCode code; + + public ErrorReasonDTO getErrorReason() { + return this.code.getReason(); + } + + public ErrorReasonDTO getErrorReasonHttpStatus(){ + return this.code.getReasonHttpStatus(); + } +} diff --git a/src/main/java/com/gdg/backend/common/exception/controller/ExceptionController.java b/src/main/java/com/gdg/backend/common/exception/controller/ExceptionController.java new file mode 100644 index 0000000..dfd64a4 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/exception/controller/ExceptionController.java @@ -0,0 +1,40 @@ +package com.gdg.backend.common.exception.controller; + + +import com.gdg.backend.common.response.ApiResponse; +import com.gdg.backend.common.response.status.ErrorCode; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.servlet.error.ErrorAttributes; +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.context.request.WebRequest; + +import java.util.Map; + +@Controller +@RequiredArgsConstructor +@Hidden // Swagger์— ๋ช…์‹œ X +public class ExceptionController implements ErrorController { + private final ErrorAttributes errorAttributes; + + @RequestMapping("/error") + @ResponseBody + public ApiResponse handlerError(WebRequest request) { + + Map errorAttributes = this.errorAttributes.getErrorAttributes(request, ErrorAttributeOptions.defaults()); + + System.out.println(errorAttributes); + + int status = (int) errorAttributes.getOrDefault("status", 500); + String message = (String) errorAttributes.getOrDefault("error", "Unexpected error"); + + System.out.println("CustomErrorController: received status : " + status); + System.out.println("CustomErrorController: error message - " + message); + + return ApiResponse.onFailure(String.valueOf(status), message, "ERROR"); + } +} \ No newline at end of file diff --git a/src/main/java/com/gdg/backend/common/exception/handler/GeneralHandler.java b/src/main/java/com/gdg/backend/common/exception/handler/GeneralHandler.java new file mode 100644 index 0000000..2edfb72 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/exception/handler/GeneralHandler.java @@ -0,0 +1,10 @@ +package com.gdg.backend.common.exception.handler; + +import com.gdg.backend.common.exception.GeneralException; +import com.gdg.backend.common.response.BaseErrorCode; + +public class GeneralHandler extends GeneralException { + public GeneralHandler(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/gdg/backend/common/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/com/gdg/backend/common/jwt/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..c2af4bd --- /dev/null +++ b/src/main/java/com/gdg/backend/common/jwt/CustomAuthenticationEntryPoint.java @@ -0,0 +1,29 @@ +package com.gdg.backend.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@Slf4j +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException ex) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + + log.info("[commence] ์ธ์ฆ ์‹คํŒจ๋กœ response.sendError ๋ฐœ์ƒ"); + + response.setStatus(401); + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + response.getWriter().write(objectMapper.writeValueAsString("์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค")); + } +} diff --git a/src/main/java/com/gdg/backend/common/jwt/CustomPasswordEncoder.java b/src/main/java/com/gdg/backend/common/jwt/CustomPasswordEncoder.java new file mode 100644 index 0000000..0939385 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/jwt/CustomPasswordEncoder.java @@ -0,0 +1,28 @@ +package com.gdg.backend.common.jwt; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +@Component +public class CustomPasswordEncoder implements PasswordEncoder { + + @Override + public String encode(CharSequence rawPassword) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hashedBytes = md.digest(rawPassword.toString().getBytes()); + return Base64.getEncoder().encodeToString(hashedBytes); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Could not encode password", e); + } + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + return encode(rawPassword).equals(encodedPassword); + } +} diff --git a/src/main/java/com/gdg/backend/common/jwt/JwtAuthenticationFilter.java b/src/main/java/com/gdg/backend/common/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..37cae4c --- /dev/null +++ b/src/main/java/com/gdg/backend/common/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,69 @@ +package com.gdg.backend.common.jwt; + +import com.gdg.backend.common.exception.handler.GeneralHandler; +import com.gdg.backend.common.response.status.ErrorCode; +import com.gdg.backend.domain.member.entity.Member; +import com.gdg.backend.domain.member.entity.UserPrincipal; +import com.gdg.backend.domain.member.repository.MemberRepository; +import io.micrometer.common.lang.NonNull; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final MemberRepository memberRepository; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + String token = extractToken(request); + + if (token != null) { + Long memberId = jwtTokenProvider.validateToken(token); + log.info("โœ… JWT ๊ฒ€์ฆ ์„ฑ๊ณต - memberId={}", memberId); + + // ํšŒ์› ์ •๋ณด ์กฐํšŒ (Member ๊ฐ์ฒด๋Š” ์‹ค์ œ Student ์ธ์Šคํ„ด์Šค์ผ ์ˆ˜ ์žˆ์Œ) + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new GeneralHandler(ErrorCode.MEMBER_NOT_FOUND)); + + log.info(member.getUsername()); + + // UserPrincipal ์ƒ์„ฑ + UserPrincipal userPrincipal = new UserPrincipal(member); + + // SecurityContext์— ์ธ์ฆ ์ •๋ณด ์ €์žฅ + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userPrincipal, null, userPrincipal.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String extractToken(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); // "Bearer " ์ œ๊ฑฐ ํ›„ ํ† ํฐ ๋ฐ˜ํ™˜ + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/gdg/backend/common/jwt/JwtTokenProvider.java b/src/main/java/com/gdg/backend/common/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..ba07050 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/jwt/JwtTokenProvider.java @@ -0,0 +1,64 @@ +package com.gdg.backend.common.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +@Slf4j +public class JwtTokenProvider { + + private final SecretKey secretKey; + private static final long EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7; // 7์ผ (๊ธฐ๋ณธ๊ฐ’, ํ•„์š”์‹œ ๋ณ€๊ฒฝ) + + public JwtTokenProvider(@Value("${jwt.secret.key}") String secret) { + if (secret == null || secret.length() < 32) { + throw new IllegalArgumentException("JWT Secret Key must be at least 32 characters long"); + } + log.info("๐Ÿ”น Loaded JWT Secret from Environment"); + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); + } + + public String generateToken(Long memberId) { + return Jwts.builder() + .setSubject(String.valueOf(memberId)) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .signWith(secretKey) // ์•Œ๊ณ ๋ฆฌ์ฆ˜ ์ƒ๋žต + .compact(); + } + + public String getMemberId(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + public Long validateToken(String token) { + try { + return Long.parseLong(Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject()); + } catch (ExpiredJwtException e) { + log.error("โŒ JWT ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + throw new JwtException("JWT ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } catch (MalformedJwtException | SignatureException e) { + log.error("โŒ ์œ ํšจํ•˜์ง€ ์•Š์€ JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค."); + throw new JwtException("์œ ํšจํ•˜์ง€ ์•Š์€ JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค."); + } catch (Exception e) { + log.error("โŒ JWT ๊ฒ€์ฆ ์˜ค๋ฅ˜: {}", e.getMessage()); + throw new JwtException("JWT ๊ฒ€์ฆ ์˜ค๋ฅ˜"); + } + } +} diff --git a/src/main/java/com/gdg/backend/common/resolver/AuthUserArgumentResolver.java b/src/main/java/com/gdg/backend/common/resolver/AuthUserArgumentResolver.java new file mode 100644 index 0000000..3fa79fd --- /dev/null +++ b/src/main/java/com/gdg/backend/common/resolver/AuthUserArgumentResolver.java @@ -0,0 +1,43 @@ +package com.gdg.backend.common.resolver; + +import com.gdg.backend.common.annotation.AuthUser; +import com.gdg.backend.common.exception.handler.GeneralHandler; +import com.gdg.backend.common.jwt.JwtTokenProvider; +import com.gdg.backend.common.response.status.ErrorCode; +import com.gdg.backend.domain.member.entity.Member; +import com.gdg.backend.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { + private final JwtTokenProvider jwtTokenProvider; + private final MemberRepository memberRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasAnnotation = parameter.hasParameterAnnotation(AuthUser.class); + boolean isMemberType = Member.class.isAssignableFrom(parameter.getParameterType()); + + return hasAnnotation && isMemberType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + String bearer = webRequest.getHeader("Authorization"); + assert bearer != null; + String token = bearer.substring(7); + Long memberId = Long.parseLong(jwtTokenProvider.getMemberId(token)); + + return memberRepository.findById(memberId).orElseThrow(() -> new GeneralHandler(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/gdg/backend/common/response/ApiResponse.java b/src/main/java/com/gdg/backend/common/response/ApiResponse.java new file mode 100644 index 0000000..0a4c1ed --- /dev/null +++ b/src/main/java/com/gdg/backend/common/response/ApiResponse.java @@ -0,0 +1,40 @@ +package com.gdg.backend.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.gdg.backend.common.response.status.SuccessCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +public class ApiResponse { + + @JsonProperty("isSuccess") + private final Boolean isSuccess; + private final String code; + private final String message; + @JsonInclude(JsonInclude.Include.NON_NULL) + private T result; + + + // ์„ฑ๊ณตํ•œ ๊ฒฝ์šฐ ์‘๋‹ต ์ƒ์„ฑ + + public static ApiResponse onSuccess(T result) { + return new ApiResponse<>(true, SuccessCode._OK.getCode(), SuccessCode._OK.getMessage(), result); + } + + public static ApiResponse of(BaseCode code, T result) { + return new ApiResponse<>(true, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), result); + } + + // ์‹คํŒจํ•œ ๊ฒฝ์šฐ ์‘๋‹ต ์ƒ์„ฑ + public static ApiResponse onFailure(String code, String message, T data) { + return new ApiResponse<>(false, code, message, data); + } + public static ApiResponse ofFailure(BaseErrorCode code, T result) { + return new ApiResponse<>(false, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), result); + } +} diff --git a/src/main/java/com/gdg/backend/common/response/BaseCode.java b/src/main/java/com/gdg/backend/common/response/BaseCode.java new file mode 100644 index 0000000..a91373b --- /dev/null +++ b/src/main/java/com/gdg/backend/common/response/BaseCode.java @@ -0,0 +1,8 @@ +package com.gdg.backend.common.response; + +public interface BaseCode { + + public ReasonDTO getReason(); + + public ReasonDTO getReasonHttpStatus(); +} diff --git a/src/main/java/com/gdg/backend/common/response/BaseErrorCode.java b/src/main/java/com/gdg/backend/common/response/BaseErrorCode.java new file mode 100644 index 0000000..0ddd25a --- /dev/null +++ b/src/main/java/com/gdg/backend/common/response/BaseErrorCode.java @@ -0,0 +1,8 @@ +package com.gdg.backend.common.response; + +public interface BaseErrorCode { + + public ErrorReasonDTO getReason(); + + public ErrorReasonDTO getReasonHttpStatus(); +} diff --git a/src/main/java/com/gdg/backend/common/response/ErrorReasonDTO.java b/src/main/java/com/gdg/backend/common/response/ErrorReasonDTO.java new file mode 100644 index 0000000..c0bb95d --- /dev/null +++ b/src/main/java/com/gdg/backend/common/response/ErrorReasonDTO.java @@ -0,0 +1,18 @@ +package com.gdg.backend.common.response; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ErrorReasonDTO { + + private HttpStatus httpStatus; + + private final boolean isSuccess; + private final String code; + private final String message; + + public boolean getIsSuccess(){return isSuccess;} +} diff --git a/src/main/java/com/gdg/backend/common/response/ReasonDTO.java b/src/main/java/com/gdg/backend/common/response/ReasonDTO.java new file mode 100644 index 0000000..c347f18 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/response/ReasonDTO.java @@ -0,0 +1,18 @@ +package com.gdg.backend.common.response; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ReasonDTO { + + private HttpStatus httpStatus; + + private final boolean isSuccess; + private final String code; + private final String message; + + public boolean getIsSuccess(){return isSuccess;} +} diff --git a/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java b/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java new file mode 100644 index 0000000..d47e7f8 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java @@ -0,0 +1,66 @@ +package com.gdg.backend.common.response.status; + +import com.gdg.backend.common.response.BaseErrorCode; +import com.gdg.backend.common.response.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode implements BaseErrorCode { + + // ๊ฐ€์žฅ ์ผ๋ฐ˜์ ์ธ ์‘๋‹ต + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "์„œ๋ฒ„ ์—๋Ÿฌ, ๊ด€๋ฆฌ์ž์—๊ฒŒ ๋ฌธ์˜ ๋ฐ”๋ž๋‹ˆ๋‹ค."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "๊ธˆ์ง€๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + + // ๋ฉค๋ฒ„ ๊ด€๋ จ ์—๋Ÿฌ + MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4001", "์‚ฌ์šฉ์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."), + MEMBER_LOGIN_FAILURE(HttpStatus.BAD_REQUEST, "MEMBER4003", "์•„์ด๋”” ํ˜น์€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž˜๋ชป ์ž…๋ ฅํ•˜์˜€์Šต๋‹ˆ๋‹ค."), + NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "MEMBER4002", "๋‹‰๋„ค์ž„์€ ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค."), + + MEMBER_SIGNUP_ERROR(HttpStatus.BAD_REQUEST, "SIGNUP4001", "ํšŒ์›๊ฐ€์ž… ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์‹คํŒจ"), + EMAIL_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "SIGNUP4002", "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์•„์ด๋””์ž…๋‹ˆ๋‹ค."), + + POST_NOTFOUND(HttpStatus.BAD_REQUEST, "POST4004", "๊ฒŒ์‹œ๋ฌผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + UNSIGNED(HttpStatus.BAD_REQUEST, "POST4001", "๋กœ๊ทธ์ธ ๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + + INVALID_CODE(HttpStatus.BAD_REQUEST, "CLASS4000", "์œ ํšจํ•˜์ง€ ์•Š์€ ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค."), + CLASS_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "CLASS4001", "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๊ฐ•์˜์‹ค๋ช…์ž…๋‹ˆ๋‹ค."), + + // ๋ฌธ์„œ ๊ด€๋ จ ์—๋Ÿฌ + DOCUMENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "DOCUMENT4001", "๋ฌธ์„œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // ๊ฐ•์˜์‹ค ๊ด€๋ จ + CLASSROOM_NOT_FOUND(HttpStatus.BAD_REQUEST, "CLASSROOM4001", "๊ฐ•์˜์‹ค์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build() + ; + } +} diff --git a/src/main/java/com/gdg/backend/common/response/status/SuccessCode.java b/src/main/java/com/gdg/backend/common/response/status/SuccessCode.java new file mode 100644 index 0000000..11830f5 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/response/status/SuccessCode.java @@ -0,0 +1,45 @@ +package com.gdg.backend.common.response.status; + +import com.gdg.backend.common.response.BaseCode; +import com.gdg.backend.common.response.ReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SuccessCode implements BaseCode { + + // ์ผ๋ฐ˜์ ์ธ ์‘๋‹ต + _OK(HttpStatus.OK, "COMMON200", "์„ฑ๊ณต์ž…๋‹ˆ๋‹ค."), + + // ํšŒ์›๊ฐ€์ž… ์‘๋‹ต + _SIGNUP_SUCCESS(HttpStatus.OK, "SIGNUP200", "ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต์ž…๋‹ˆ๋‹ค."), + _LOGIN_SUCCESS(HttpStatus.OK, "LOGIN200", "๋กœ๊ทธ์ธ ์„ฑ๊ณต์ž…๋‹ˆ๋‹ค."), + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDTO getReason() { + return ReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(true) + .build(); + } + + @Override + public ReasonDTO getReasonHttpStatus() { + return ReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(true) + .httpStatus(httpStatus) + .build() + ; + } +} diff --git a/src/main/java/com/gdg/backend/common/util/RandomCodeGenerator.java b/src/main/java/com/gdg/backend/common/util/RandomCodeGenerator.java new file mode 100644 index 0000000..807b387 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/util/RandomCodeGenerator.java @@ -0,0 +1,18 @@ +package com.gdg.backend.common.util; + +import java.security.SecureRandom; + +public class RandomCodeGenerator { + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final SecureRandom RANDOM = new SecureRandom(); + + public static String getRandomCode(int length) { + StringBuilder code = new StringBuilder(length); + for (int i = 0; i < length; i++) { + int index = RANDOM.nextInt(CHARACTERS.length()); + code.append(CHARACTERS.charAt(index)); + } + return code.toString(); + } +} + diff --git a/src/main/java/com/gdg/backend/config/MessageQueueConfig.java b/src/main/java/com/gdg/backend/config/MessageQueueConfig.java new file mode 100644 index 0000000..16ffd3f --- /dev/null +++ b/src/main/java/com/gdg/backend/config/MessageQueueConfig.java @@ -0,0 +1,26 @@ +package com.gdg.backend.config; + +import com.gdg.backend.domain.operation.dto.OperationRequestDto; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + + +/** ์ธ๋ฉ”๋ชจ๋ฆฌ ๋ฉ”์‹œ์ง€ ํ ์„ค์ • */ +@Configuration +public class MessageQueueConfig { + + /** + * Operation ์ฒ˜๋ฆฌ์šฉ ๋ฉ”์‹œ์ง€ ํ
+ * - BlockingQueue ํƒ€์ž…์ด๋ฏ€๋กœ operation์ด ๋“ค์–ด์˜ฌ ๋•Œ๊นŒ์ง€ / ํ์— ๋นˆ ๊ณต๊ฐ„์ด ์ƒ๊ธธ ๋•Œ๊นŒ์ง€ waitํ•˜๋Š” ๋ฉ”์†Œ๋“œ ์ œ๊ณต
+ * - ์šฉ๋Ÿ‰์€ ์ผ๋‹จ 1000์œผ๋กœ ์„ธํŒ… (๊ฝ‰ ์ฐฌ ํ›„์˜ ๋ฉ”์‹œ์ง€๋Š” ๊ณต๊ฐ„ ์ƒ๊ธธ ๋•Œ๊นŒ์ง€ wait)
+ * */ + @Bean + public BlockingQueue eventQueue() { + // TODO Document๋งˆ๋‹ค ๋ฉ”์‹œ์ง€ํ ๋”ฐ๋กœ ๋งˆ๋ จํ•˜๊ธฐ (Map ํ˜•์‹์œผ๋กœ) + // TODO ์•„๋‹ˆ๋ฉด ์•„์˜ˆ Redis๋‚˜ RabbitMQ ๋“ฑ๋“ฑ ์™ธ๋ถ€ ๋ฉ”์‹œ์ง€ ํ๋กœ ์˜ฎ๊ธฐ๊ธฐ (์˜ฎ๊ธธ ๋• ์„ ํƒ์ด์œ ๋„ ๊ฐ™์ด ์ƒ๊ฐํ•ด๋‘๊ธฐ!) + return new LinkedBlockingQueue<>(1000); + } +} diff --git a/src/main/java/com/gdg/backend/config/SecurityConfig.java b/src/main/java/com/gdg/backend/config/SecurityConfig.java new file mode 100644 index 0000000..8d09e77 --- /dev/null +++ b/src/main/java/com/gdg/backend/config/SecurityConfig.java @@ -0,0 +1,38 @@ +package com.gdg.backend.config; + +import com.gdg.backend.common.jwt.CustomAuthenticationEntryPoint; +import com.gdg.backend.common.jwt.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { + return httpSecurity + .httpBasic(AbstractHttpConfigurer::disable) // ๊ธฐ๋ณธ UI ๋น„ํ™œ์„ฑํ™” + .cors(AbstractHttpConfigurer::disable) // CORS ๋น„ํ™œ์„ฑํ™” + .csrf(AbstractHttpConfigurer::disable) // CSRF ๋น„ํ™œ์„ฑํ™” + .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) // H2 ์ฝ˜์†” ํ—ˆ์šฉ + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // ์„ธ์…˜ ์‚ฌ์šฉ ์•ˆ ํ•จ + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() // ๋ชจ๋“  ์š”์ฒญ ํ—ˆ์šฉ + ) + .exceptionHandling(exceptionConfig -> + exceptionConfig.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/src/main/java/com/gdg/backend/config/SwaggerConfig.java b/src/main/java/com/gdg/backend/config/SwaggerConfig.java new file mode 100644 index 0000000..8a4bf66 --- /dev/null +++ b/src/main/java/com/gdg/backend/config/SwaggerConfig.java @@ -0,0 +1,45 @@ +package com.gdg.backend.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration // Spring์—์„œ ์„ค์ • ํด๋ž˜์Šค๋กœ ์‚ฌ์šฉ๋จ์„ ๋ช…์‹œ +public class SwaggerConfig { + + @Bean // Spring ์ปจํ…์ŠคํŠธ์—์„œ SchrodingerApi ๋ฉ”์„œ๋“œ์˜ ๋ฐ˜ํ™˜๊ฐ’์„ ๋นˆ์œผ๋กœ ๋“ฑ๋ก + public OpenAPI SchrodingerApi() { + // Swagger UI์—์„œ API ๋ฌธ์„œ์˜ ์ •๋ณด๋ฅผ ์„ค์ • + Info info = new Info() + .title("") // API์˜ ์ œ๋ชฉ ์„ค์ • (ํ˜„์žฌ ๋นˆ ๋ฌธ์ž์—ด) + .description("") // API์˜ ์„ค๋ช… ์„ค์ • (ํ˜„์žฌ ๋นˆ ๋ฌธ์ž์—ด) + .version("1.0.0"); // API์˜ ๋ฒ„์ „ ์„ค์ • + + // JWT ์ธ์ฆ ์Šคํ‚ค๋งˆ์˜ ์ด๋ฆ„์„ ์ •์˜ + String jwtSchemeName = "JWT TOKEN"; + + // Swagger์—์„œ ๋ณด์•ˆ์„ ์ ์šฉํ•  ๋•Œ ์‚ฌ์šฉํ•  SecurityRequirement๋ฅผ ์ •์˜ + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + + // ๋ณด์•ˆ ์Šคํ‚ค๋งˆ๋ฅผ ๊ตฌ์„ฑํ•˜๋Š” Components๋ฅผ ์ƒ์„ฑ + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) // ๋ณด์•ˆ ์Šคํ‚ค๋งˆ์˜ ์ด๋ฆ„ ์„ค์ • + .type(SecurityScheme.Type.HTTP) // HTTP ์ธ์ฆ ๋ฐฉ์‹ ์‚ฌ์šฉ + .scheme("bearer") // Bearer ์ธ์ฆ ๋ฐฉ์‹ ์‚ฌ์šฉ + .bearerFormat("JWT")); // Bearer ํ† ํฐ์˜ ํ˜•์‹์ด JWT์ž„์„ ๋ช…์‹œ + + // OpenAPI ๊ฐ์ฒด ์ƒ์„ฑ ๋ฐ ๊ตฌ์„ฑ + return new OpenAPI() + .addServersItem(new Server().url("/")) // ๊ธฐ๋ณธ ์„œ๋ฒ„ URL ์„ค์ • (ํ˜„์žฌ ๋ฃจํŠธ ๊ฒฝ๋กœ) + .info(info) // API ์ •๋ณด ์ถ”๊ฐ€ + .addSecurityItem(securityRequirement) // ๋ณด์•ˆ ์š”๊ตฌ ์‚ฌํ•ญ ์ถ”๊ฐ€ + .components(components); // ๋ณด์•ˆ ์Šคํ‚ค๋งˆ๋ฅผ ํฌํ•จํ•œ ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€ + } +} + diff --git a/src/main/java/com/gdg/backend/config/WebConfig.java b/src/main/java/com/gdg/backend/config/WebConfig.java new file mode 100644 index 0000000..60475eb --- /dev/null +++ b/src/main/java/com/gdg/backend/config/WebConfig.java @@ -0,0 +1,32 @@ +package com.gdg.backend.config; + +import com.gdg.backend.common.resolver.AuthUserArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final AuthUserArgumentResolver authUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authUserArgumentResolver); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://localhost:3000") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); + } + +} diff --git a/src/main/java/com/gdg/backend/config/WebsocketConfig.java b/src/main/java/com/gdg/backend/config/WebsocketConfig.java new file mode 100644 index 0000000..824e8e6 --- /dev/null +++ b/src/main/java/com/gdg/backend/config/WebsocketConfig.java @@ -0,0 +1,26 @@ +package com.gdg.backend.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.*; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebsocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/sub"); // ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ์— ๋‚ด์žฅ ๋ธŒ๋กœ์ปค ์‚ฌ์šฉ & '/sub/**' ๊ฒฝ๋กœ๋กœ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ + config.setApplicationDestinationPrefixes("/pub"); // ํด๋ผ์ด์–ธํŠธ๋Š” '/pub'์œผ๋กœ ๋ฉ”์‹œ์ง€ ์ „์†ก + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry config) { + config.addEndpoint("/ws") // '/ws'๋กœ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ ์—”๋“œํฌ์ธํŠธ ์„ค์ • + .setAllowedOriginPatterns("*") // CORS ํ—ˆ์šฉ ์„ค์ • + .withSockJS(); // SockJS ํ—ˆ์šฉ (๋ธŒ๋ผ์šฐ์ € ํ˜ธํ™˜์„ฑ) + } + +} diff --git a/src/main/java/com/gdg/backend/domain/assignment/entity/Assignment.java b/src/main/java/com/gdg/backend/domain/assignment/entity/Assignment.java new file mode 100644 index 0000000..f1881cf --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/assignment/entity/Assignment.java @@ -0,0 +1,23 @@ +package com.gdg.backend.domain.assignment.entity; + +import com.gdg.backend.common.entity.BaseTimeEntity; +import com.gdg.backend.domain.classroom.entity.Classroom; + +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +public class Assignment extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String content; + + @ManyToOne + @JoinColumn(name = "classroom_id") + private Classroom classroom; + +} diff --git a/src/main/java/com/gdg/backend/domain/assignment/repository/AssignmentRepository.java b/src/main/java/com/gdg/backend/domain/assignment/repository/AssignmentRepository.java new file mode 100644 index 0000000..b0d9491 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/assignment/repository/AssignmentRepository.java @@ -0,0 +1,7 @@ +package com.gdg.backend.domain.assignment.repository; + +import com.gdg.backend.domain.assignment.entity.Assignment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AssignmentRepository extends JpaRepository { +} diff --git a/src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java b/src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java new file mode 100644 index 0000000..e8a84fd --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java @@ -0,0 +1,25 @@ +package com.gdg.backend.domain.attendence.entity; + +import com.gdg.backend.common.entity.BaseTimeEntity; +import com.gdg.backend.domain.course.entity.Course; +import com.gdg.backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +public class Attendance extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "class_id") + private Course course; + +} diff --git a/src/main/java/com/gdg/backend/domain/build/BuildRepository.java b/src/main/java/com/gdg/backend/domain/build/BuildRepository.java new file mode 100644 index 0000000..5e3c214 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/build/BuildRepository.java @@ -0,0 +1,13 @@ +package com.gdg.backend.domain.build; + +import com.gdg.backend.domain.build.entity.Build; +import com.gdg.backend.domain.classroom.entity.Classroom; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface BuildRepository extends JpaRepository { + + List findAllByClassroom(Classroom classroom); + +} diff --git a/src/main/java/com/gdg/backend/domain/build/controller/BuildController.java b/src/main/java/com/gdg/backend/domain/build/controller/BuildController.java new file mode 100644 index 0000000..f612010 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/build/controller/BuildController.java @@ -0,0 +1,28 @@ +package com.gdg.backend.domain.build.controller; + +import com.gdg.backend.domain.build.dto.BuildRequest; +import com.gdg.backend.domain.build.dto.InputMessage; +import com.gdg.backend.domain.build.service.CodeExecutionService; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +@Controller +@RequiredArgsConstructor +public class BuildController { + + private final CodeExecutionService codeExecutionService; + + // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ฝ”๋“œ ์‹คํ–‰ ์š”์ฒญ์„ ๋ณด๋ƒˆ์„ ๋•Œ + @MessageMapping("/compile") + public void compileCode(BuildRequest request) { + // Docker ์ปจํ…Œ์ด๋„ˆ์—์„œ ์‹คํ–‰ + codeExecutionService.runCode(request); + } + + // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์‹คํ–‰ ๋„์ค‘ ์ž…๋ ฅ์„ ๋ณด๋ƒˆ์„ ๋•Œ + @MessageMapping("/input") + public void handleInput(InputMessage inputMessage) { + codeExecutionService.sendInput(inputMessage.getSessionId(), inputMessage.getInput()); + } +} \ No newline at end of file diff --git a/src/main/java/com/gdg/backend/domain/build/dto/BuildRequest.java b/src/main/java/com/gdg/backend/domain/build/dto/BuildRequest.java new file mode 100644 index 0000000..d72b993 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/build/dto/BuildRequest.java @@ -0,0 +1,15 @@ +package com.gdg.backend.domain.build.dto; + +import com.gdg.backend.domain.enums.LanguageType; +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class BuildRequest { + private String ideId; + private String language; + private String code; +} diff --git a/src/main/java/com/gdg/backend/domain/build/dto/BuildResponse.java b/src/main/java/com/gdg/backend/domain/build/dto/BuildResponse.java new file mode 100644 index 0000000..bbc170e --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/build/dto/BuildResponse.java @@ -0,0 +1,13 @@ +package com.gdg.backend.domain.build.dto; + +import com.gdg.backend.domain.enums.LanguageType; +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class BuildResponse { + private String output; +} diff --git a/src/main/java/com/gdg/backend/domain/build/dto/InputMessage.java b/src/main/java/com/gdg/backend/domain/build/dto/InputMessage.java new file mode 100644 index 0000000..bc3f40c --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/build/dto/InputMessage.java @@ -0,0 +1,13 @@ +package com.gdg.backend.domain.build.dto; + +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class InputMessage { + private String sessionId; // ์‹คํ–‰ ์ค‘์ธ ์„ธ์…˜์„ ์‹๋ณ„ํ•˜๊ธฐ ์œ„ํ•œ ๊ณ ์œ  ID + private String input; // ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ๊ฐ’ (์˜ˆ: ํ‘œ์ค€ ์ž…๋ ฅ์œผ๋กœ ์ „๋‹ฌ๋  ๋ฐ์ดํ„ฐ) +} diff --git a/src/main/java/com/gdg/backend/domain/build/entity/Build.java b/src/main/java/com/gdg/backend/domain/build/entity/Build.java new file mode 100644 index 0000000..5da9355 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/build/entity/Build.java @@ -0,0 +1,34 @@ +package com.gdg.backend.domain.build.entity; + +import com.gdg.backend.common.entity.BaseTimeEntity; +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +public class Build extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "document_id") + private Document document; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "classroom_id") + private Classroom classroom; + + private String result; +} + diff --git a/src/main/java/com/gdg/backend/domain/build/service/CodeExecutionService.java b/src/main/java/com/gdg/backend/domain/build/service/CodeExecutionService.java new file mode 100644 index 0000000..1b83fbd --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/build/service/CodeExecutionService.java @@ -0,0 +1,185 @@ +package com.gdg.backend.domain.build.service; + +import com.gdg.backend.common.exception.handler.GeneralHandler; +import com.gdg.backend.common.response.status.ErrorCode; +import com.gdg.backend.domain.build.BuildRepository; +import com.gdg.backend.domain.build.dto.BuildRequest; +import com.gdg.backend.domain.build.entity.Build; +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.document.repository.DocumentRepository; +import com.gdg.backend.domain.mapping.IdeMember; +import com.gdg.backend.domain.mapping.repository.IdeMemberRepository; +import com.gdg.backend.domain.member.entity.Member; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CodeExecutionService { + + private final SimpMessagingTemplate messagingTemplate; + private final DocumentRepository documentRepository; + private final BuildRepository buildRepository; + private final IdeMemberRepository ideMemberRepository; + // ์„ธ์…˜๋ณ„ ์‹คํ–‰ ํ”„๋กœ์„ธ์Šค๋ฅผ ์ €์žฅํ•˜๋Š” ๋งต + private final Map processMap = new ConcurrentHashMap<>(); + private final Map processOutputStreamMap = new ConcurrentHashMap<>(); + + public String runCode(BuildRequest request) { + + log.info("์ž…๋ ฅ ๋ฐ›์€ ์ฝ”๋“œ : {}", request.getCode()); + + Document document = documentRepository.findById(Long.valueOf(request.getIdeId())).orElseThrow(()-> new GeneralHandler(ErrorCode._BAD_REQUEST)); + IdeMember ideMember = ideMemberRepository.findByDocument(document).orElseThrow(()-> new GeneralHandler(ErrorCode._BAD_REQUEST)); + Classroom classroom = ideMember.getClassroom(); + Member member = ideMember.getMember(); + + try { + // 1. ์ž„์‹œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ ๋ฐ ์ฝ”๋“œ ํŒŒ์ผ ์ €์žฅ + Path tempDir = Files.createTempDirectory("code"); + String language = request.getLanguage().toLowerCase(); + String fileName; + String imageName; + String command; + + switch (language) { + case "java": + log.info("์ž๋ฐ” ์‹คํ–‰"); + fileName = "Main.java"; + imageName = "openjdk:11"; + // javac๋กœ ์ปดํŒŒ์ผ ํ›„, stdbuf๋ฅผ ์ด์šฉํ•ด unbuffered ์‹คํ–‰ + command = "javac Main.java && stdbuf -o0 java Main"; + break; + case "c": + log.info("C ์‹คํ–‰"); + fileName = "code.c"; + imageName = "gcc:latest"; + // gcc๋กœ ์ปดํŒŒ์ผ ํ›„, stdbuf๋กœ unbuffered ์‹คํ–‰ + command = "gcc code.c -o code && stdbuf -o0 ./code"; + break; + case "python": + log.info("python ์‹คํ–‰"); + fileName = "script.py"; + imageName = "python:3.8"; + // -u ์˜ต์…˜์„ ์‚ฌ์šฉํ•ด unbuffered ์‹คํ–‰ + command = "python -u script.py"; + break; + default: + throw new IllegalArgumentException("์ง€์›ํ•˜์ง€ ์•Š๋Š” ์–ธ์–ด์ž…๋‹ˆ๋‹ค."); + } + + Path codeFile = tempDir.resolve(fileName); + Files.write(codeFile, request.getCode().getBytes()); + + // 2. Docker ์ปจํ…Œ์ด๋„ˆ ์‹คํ–‰ (์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ๋ชจ๋“œ) + List cmd = List.of( + "docker", "run", "--rm", "-i", // <-- "-t" ์ถ”๊ฐ€ + "-v", tempDir.toAbsolutePath() + ":/workspace", + "-w", "/workspace", imageName, + "bash", "-c", command + ); + + ProcessBuilder pb = new ProcessBuilder(cmd); + Process process = pb.start(); + + messagingTemplate.convertAndSend("/sub/output/" + request.getIdeId(), "[INFO] ํ”„๋กœ์„ธ์Šค๊ฐ€ ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + + // 3. ์ƒ์„ฑ๋œ ์„ธ์…˜ ID๋กœ ํ”„๋กœ์„ธ์Šค๋ฅผ ๋งคํ•‘ + processMap.put(request.getIdeId(), process); + processOutputStreamMap.put(request.getIdeId(), process.getOutputStream()); + + // 4. ์‹คํ–‰ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ”๋กœ ํด๋ผ์ด์–ธํŠธ์— ์ŠคํŠธ๋ฆฌ๋ฐ + new Thread(() -> streamOutput(request.getIdeId(), process, document, classroom, member)).start(); + + } catch (Exception e) { + e.printStackTrace(); + } + // ์ƒ์„ฑ๋œ ์„ธ์…˜ ID๋ฅผ ๋ฐ˜ํ™˜ํ•˜์—ฌ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ดํ›„ ํ†ต์‹ ์— ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•จ + return request.getIdeId(); + } + + private void streamOutput(String sessionId, Process process, Document document, Classroom classroom, Member member) { + + log.info("์„ธ์…˜ id : {}", sessionId); + StringBuilder result = new StringBuilder(); + StringBuilder error = new StringBuilder(); + + try ( + BufferedReader stdOut = new BufferedReader(new InputStreamReader(process.getInputStream())); + BufferedReader stdErr = new BufferedReader(new InputStreamReader(process.getErrorStream())) + ) { + String line; + // ํ‘œ์ค€ ์ถœ๋ ฅ(STDOUT) ์ŠคํŠธ๋ฆฌ๋ฐ + while ((line = stdOut.readLine()) != null) { + messagingTemplate.convertAndSend("/sub/output/" + sessionId, line); + result.append(line); + } + + // ํ‘œ์ค€ ์—๋Ÿฌ(STDERR) ์ŠคํŠธ๋ฆฌ๋ฐ + while ((line = stdErr.readLine()) != null) { + messagingTemplate.convertAndSend("/sub/output/" + sessionId, "[ERROR] " + line); + error.append(line); + } + + saveBuildResult(document, classroom, result.toString(), error.toString(), member); + + // ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ข…๋ฃŒ๋œ ํ›„ ์ข…๋ฃŒ ๋ฉ”์‹œ์ง€ ์ „์†ก + messagingTemplate.convertAndSend("/sub/output/" + sessionId, "[INFO] ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + + } catch (IOException e) { + log.error("์ถœ๋ ฅ ์ŠคํŠธ๋ฆผ ์ฝ๊ธฐ ์˜ค๋ฅ˜", e); + } finally { + // ํ”„๋กœ์„ธ์Šค์™€ ์ถœ๋ ฅ ์ŠคํŠธ๋ฆผ ํŒŒ์ดํ”„๋ฅผ ์ •๋ฆฌ (์„ธ์…˜ ์ œ๊ฑฐ) + processMap.remove(sessionId); + OutputStream os = processOutputStreamMap.remove(sessionId); + if (os != null) { + try { + os.close(); + } catch (IOException e) { + log.error("OutputStream ๋‹ซ๊ธฐ ์‹คํŒจ", e); + } + } + } + } + + // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ „์†กํ•œ ์ž…๋ ฅ์„ ์‹คํ–‰ ์ค‘์ธ ํ”„๋กœ์„ธ์Šค๋กœ ์ „๋‹ฌ + public void sendInput(String sessionId, String input) { + OutputStream os = processOutputStreamMap.get(sessionId); + if (os != null) { + try { + os.write((input + "\n").getBytes()); + os.flush(); + log.info("์ž…๋ ฅ ๋ฐ์ดํ„ฐ ์ „์†ก: {}", input); + } catch (IOException e) { + log.error("์ž…๋ ฅ ๋ฐ์ดํ„ฐ ์ „์†ก ์‹คํŒจ", e); + } + } else { + log.error("์„ธ์…˜ {}์— ๋Œ€ํ•œ ์ถœ๋ ฅ ์ŠคํŠธ๋ฆผ์ด ์—†์Šต๋‹ˆ๋‹ค.", sessionId); + } + } + + private void saveBuildResult(Document document, Classroom classroom, String result, String error, Member member) { + + // Build ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ๋ฐ ์ €์žฅ + Build build = new Build(); + build.setDocument(document); + build.setMember(member); + build.setClassroom(classroom); + build.setResult(error.isEmpty() ? result : "[ERROR]\n" + error); + + buildRepository.save(build); + log.info("๋นŒ๋“œ ๊ฒฐ๊ณผ ์ €์žฅ ์™„๋ฃŒ"); + } +} diff --git a/src/main/java/com/gdg/backend/domain/channel/entity/Channel.java b/src/main/java/com/gdg/backend/domain/channel/entity/Channel.java new file mode 100644 index 0000000..026bc25 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/channel/entity/Channel.java @@ -0,0 +1,8 @@ +package com.gdg.backend.domain.channel.entity; + + +import lombok.Getter; + +@Getter +public class Channel { +} diff --git a/src/main/java/com/gdg/backend/domain/classroom/controller/ClassroomController.java b/src/main/java/com/gdg/backend/domain/classroom/controller/ClassroomController.java new file mode 100644 index 0000000..0d1743a --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/controller/ClassroomController.java @@ -0,0 +1,41 @@ +package com.gdg.backend.domain.classroom.controller; + +import com.gdg.backend.common.annotation.AuthUser; +import com.gdg.backend.common.response.ApiResponse; +import com.gdg.backend.domain.classroom.dto.ClassroomDto; +import com.gdg.backend.domain.classroom.service.ClassroomService; +import com.gdg.backend.domain.member.entity.Member; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +@Tag(name = "๊ฐ•์˜์‹ค ๊ด€๋ จ API", description = "๊ฐ•์˜์‹ค ๊ด€๋ จ API์ž…๋‹ˆ๋‹ค") +public class ClassroomController { + + private final ClassroomService classroomService; + + //TODO ์ผ๋‹จ ์ œ์ผ ์‰ฌ์šด ๋ฐฉ๋ฒ•์œผ๋กœ + @PostMapping("/classroom/add") + @Operation(summary = "๊ฐ•์˜์‹ค ์ถ”๊ฐ€") + public ApiResponse addClassroom(@RequestParam String name, @AuthUser Member member) { + return ApiResponse.onSuccess(classroomService.createClassroom(name, member)); + } + + //TODO ์ผ๋‹จ ์ œ์ผ ์‰ฌ์šด ๋ฐฉ๋ฒ•์œผ๋กœ + @PostMapping("/classroom/enter") + @Operation(summary = "๊ฐ•์˜์‹ค ์ž…์žฅ") + public ApiResponse enterClassroom(@RequestParam String code, @AuthUser Member member) { + return ApiResponse.onSuccess(classroomService.enterClassroom(code, member)); + } + + @GetMapping("/classroom/{classroomId}") + @Operation(summary = "๊ฐ•์˜์‹ค ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ") + public ApiResponse getClassroomInfo(@PathVariable Long classroomId, @AuthUser Member member) { + return ApiResponse.onSuccess(classroomService.getClassroomInfo(classroomId, member)); + } + +} diff --git a/src/main/java/com/gdg/backend/domain/classroom/dto/ClassroomDto.java b/src/main/java/com/gdg/backend/domain/classroom/dto/ClassroomDto.java new file mode 100644 index 0000000..17c107f --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/dto/ClassroomDto.java @@ -0,0 +1,60 @@ +package com.gdg.backend.domain.classroom.dto; + +import com.gdg.backend.domain.enums.MemberType; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +public class ClassroomDto { + + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ClassroomResponseDto { + private Long studentIdeId; + private Long teacherIdeId; + private String className; + private List studentList; + private List noticeList; + private List assignmentList; + private List buildHistoryList; + + } + + @Getter + @Setter + @AllArgsConstructor + public static class NoticeDto { + private String title; + private String content; + private LocalDateTime createdAt; + } + + @Getter + @Setter + @AllArgsConstructor + public static class AssignmentDto { + private String content; + private LocalDateTime createdAt; + } + + @Getter + @Setter + @AllArgsConstructor + public static class BuildHistoryDto { + private String member; + private String result; + private LocalDateTime createdAt; + } + + @Getter + @Setter + @AllArgsConstructor + public static class StudentDto { + private String memberName; + private Long studentId; + } +} diff --git a/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java b/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java new file mode 100644 index 0000000..10cd71a --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java @@ -0,0 +1,53 @@ +package com.gdg.backend.domain.classroom.entity; + +import com.gdg.backend.common.exception.handler.GeneralHandler; +import com.gdg.backend.common.response.status.ErrorCode; +import com.gdg.backend.domain.course.entity.Course; +import com.gdg.backend.domain.invitation.entity.Invitation; +import com.gdg.backend.domain.member.entity.Member; +import com.gdg.backend.domain.member.entity.Teacher; +import com.gdg.backend.domain.notice.entity.Notice; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class Classroom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; // ๊ฐ•์˜์‹ค ์ด๋ฆ„ + + private String invitationCode; + + @ManyToOne + private Teacher teacher; + + @OneToMany(mappedBy = "classroom") + private List courses; + + @OneToMany(mappedBy = "classroom") + private List notices; + + @OneToMany(mappedBy = "classroom") + private List invitations; + + public Classroom(String name, String randomCode, Member member) { + this.name = name; + this.invitationCode = randomCode; + + if(member instanceof Teacher){ + this.teacher = (Teacher) member; + } else { + throw new GeneralHandler(ErrorCode._FORBIDDEN); + } + } +} diff --git a/src/main/java/com/gdg/backend/domain/classroom/repository/ClassroomRepository.java b/src/main/java/com/gdg/backend/domain/classroom/repository/ClassroomRepository.java new file mode 100644 index 0000000..841275b --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/repository/ClassroomRepository.java @@ -0,0 +1,14 @@ +package com.gdg.backend.domain.classroom.repository; + +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.member.entity.Teacher; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ClassroomRepository extends JpaRepository { + Optional findByInvitationCode(String invitationCode); + Boolean existsByTeacherAndName(Teacher teacher, String name); + List findAllByTeacher(Teacher teacher); +} diff --git a/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomService.java b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomService.java new file mode 100644 index 0000000..d16b3d8 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomService.java @@ -0,0 +1,14 @@ +package com.gdg.backend.domain.classroom.service; + +import com.gdg.backend.domain.classroom.dto.ClassroomDto; +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.member.entity.Member; + +public interface ClassroomService { + + String createClassroom(String name, Member member); + + String enterClassroom(String code, Member member); + + ClassroomDto.ClassroomResponseDto getClassroomInfo(Long classroomId, Member member); +} diff --git a/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomServiceImpl.java b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomServiceImpl.java new file mode 100644 index 0000000..5f2f607 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomServiceImpl.java @@ -0,0 +1,179 @@ +package com.gdg.backend.domain.classroom.service; + +import com.gdg.backend.common.exception.handler.GeneralHandler; +import com.gdg.backend.common.response.status.ErrorCode; +import com.gdg.backend.domain.assignment.entity.Assignment; +import com.gdg.backend.domain.assignment.repository.AssignmentRepository; +import com.gdg.backend.domain.build.BuildRepository; +import com.gdg.backend.domain.build.entity.Build; +import com.gdg.backend.domain.classroom.dto.ClassroomDto; +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.classroom.repository.ClassroomRepository; +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.document.repository.DocumentRepository; +import com.gdg.backend.domain.invitation.entity.Invitation; +import com.gdg.backend.domain.invitation.repository.InvitationRepository; +import com.gdg.backend.domain.mapping.IdeMember; +import com.gdg.backend.domain.mapping.repository.IdeMemberRepository; +import com.gdg.backend.domain.member.entity.Member; +import com.gdg.backend.domain.member.entity.Student; +import com.gdg.backend.domain.member.entity.Teacher; +import com.gdg.backend.domain.member.repository.StudentRepository; +import com.gdg.backend.domain.notice.entity.Notice; +import com.gdg.backend.domain.notice.repository.NoticeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.print.Doc; + +import java.util.ArrayList; +import java.util.List; + +import static com.gdg.backend.common.util.RandomCodeGenerator.getRandomCode; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ClassroomServiceImpl implements ClassroomService { + + private final ClassroomRepository classroomRepository; + private final InvitationRepository invitationRepository; + private final DocumentRepository documentRepository; + private final IdeMemberRepository ideMemberRepository; + private final NoticeRepository noticeRepository; + private final AssignmentRepository assignmentRepository; + private final BuildRepository buildRepository; + + @Override + @Transactional + public String createClassroom(String name, Member member) { + + // ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ + if(classroomRepository.existsByTeacherAndName((Teacher) member, name)){ + throw new GeneralHandler(ErrorCode.CLASS_ALREADY_EXIST); + } + + String randomCode = getRandomCode(7); + + Classroom classroom = new Classroom(name, randomCode, member); + classroomRepository.save(classroom); + + // ์„ ์ƒ๋‹˜์˜ ide ์ƒ์„ฑํ•˜๊ธฐ + Document document = new Document(); + document.setVersion(0L); + document.setContent("Enter Your Code"); + documentRepository.save(document); + + // ide ์ •๋ณด ๋งคํ•‘ํ•˜๊ธฐ + IdeMember ideMember = new IdeMember(document, member, classroom); + ideMemberRepository.save(ideMember); + + return randomCode; + } + + @Override + public String enterClassroom(String code, Member member) { + + // ์œ ํšจํ•˜์ง€ ์•Š์€ ์ฝ”๋“œ์ธ ๊ฒฝ์šฐ + Classroom classroom = classroomRepository.findByInvitationCode(code).orElseThrow(()-> new GeneralHandler(ErrorCode.INVALID_CODE)); + + // ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ + if(invitationRepository.existsByMemberAndClassroom(member, classroom)){ + throw new GeneralHandler(ErrorCode.CLASS_ALREADY_EXIST); + } + + if(member instanceof Student) { + Invitation invitation = new Invitation(member, classroom); + invitationRepository.save(invitation); + } else { + throw new GeneralHandler(ErrorCode._FORBIDDEN); + } + + // ํ•™์ƒ์˜ ide ์ƒ์„ฑํ•˜๊ธฐ + Document document = new Document(); + document.setVersion(0L); + document.setContent("Enter Your Code"); + documentRepository.save(document); + + // ide ์ •๋ณด ๋งคํ•‘ํ•˜๊ธฐ + IdeMember ideMember = new IdeMember(document, member, classroom); + ideMemberRepository.save(ideMember); + + return "success"; + } + + @Override + public ClassroomDto.ClassroomResponseDto getClassroomInfo(Long classroomId, Member member) { + + + ClassroomDto.ClassroomResponseDto classroomResponseDto = new ClassroomDto.ClassroomResponseDto(); + + // ๊ฐ•์˜์‹ค ์ •๋ณด ์ฐพ๊ธฐ + Classroom classroom = classroomRepository.findById(classroomId).orElseThrow(()-> new GeneralHandler(ErrorCode.CLASSROOM_NOT_FOUND)); + + // ํ•ด๋‹น ide id ์ฐพ๊ธฐ + IdeMember ideMember = ideMemberRepository.findByMemberAndClassroom(member, classroom).orElseThrow(()-> new GeneralHandler(ErrorCode.CLASSROOM_NOT_FOUND)); + + // ์„ ์ƒ๋‹˜์˜ ide ๊ฐ€์ ธ์˜ค๊ธฐ + IdeMember ideTeacher = ideMemberRepository.findByMemberAndClassroom(classroom.getTeacher(), classroom).orElseThrow(()-> new GeneralHandler(ErrorCode.CLASSROOM_NOT_FOUND)); + + classroomResponseDto.setTeacherIdeId(ideTeacher.getDocument().getId()); + + // ํ•™์ƒ์˜ ide ๊ฐ€์ ธ์˜ค๊ธฐ + if(member instanceof Student) { + classroomResponseDto.setStudentIdeId(ideMember.getDocument().getId()); + } + + // ํด๋ž˜์Šค๋ช… ๊ฐ€์ ธ์˜ค๊ธฐ + classroomResponseDto.setClassName(classroom.getName()); + + // ๋“ฑ๋ก๋œ ํ•™์ƒ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + List invitationList = invitationRepository.findAllByClassroom(classroom); + List studentDtos = new ArrayList<>(); + + invitationList.forEach(invitation -> { + Member student = invitation.getMember(); + studentDtos.add(new ClassroomDto.StudentDto(student.getUsername(), student.getId())); + }); + + // ๋“ฑ๋ก๋œ ๊ณต์ง€ ์‚ฌํ•ญ ๊ฐ€์ ธ์˜ค๊ธฐ + // TODO ์ž„์‹œ๋กœ ๊ตฌํ˜„ + List noticeList = noticeRepository.findAll(); + List noticeDtos = new ArrayList<>(); + + noticeList.forEach(notice -> { + noticeDtos.add(new ClassroomDto.NoticeDto(notice.getTitle(), notice.getContent(), notice.getCreatedDate())); + }); + + // ๋“ฑ๋ก๋œ ๊ณผ์ œ ๊ฐ€์ ธ์˜ค๊ธฐ + // TODO ์ž„์‹œ๋กœ ๊ตฌํ˜„ + List assignmentList = assignmentRepository.findAll(); + List assignmentDtos = new ArrayList<>(); + + assignmentList.forEach(assignment -> { + assignmentDtos.add(new ClassroomDto.AssignmentDto(assignment.getContent(), assignment.getCreatedDate())); + }); + + // ๋นŒ๋“œ ๊ธฐ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ + List buildList = buildRepository.findAllByClassroom(classroom); + List buildHistoryDtos = new ArrayList<>(); + + buildList.forEach(build -> { + buildHistoryDtos.add( + new ClassroomDto.BuildHistoryDto( + build.getMember().getUsername(), + build.getResult(), + build.getCreatedDate())); + }); + + classroomResponseDto.setNoticeList(noticeDtos); + classroomResponseDto.setAssignmentList(assignmentDtos); + classroomResponseDto.setStudentList(studentDtos); + classroomResponseDto.setBuildHistoryList(buildHistoryDtos); + + return classroomResponseDto; + } + +} diff --git a/src/main/java/com/gdg/backend/domain/course/entity/Course.java b/src/main/java/com/gdg/backend/domain/course/entity/Course.java new file mode 100644 index 0000000..ec509d2 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/course/entity/Course.java @@ -0,0 +1,31 @@ +package com.gdg.backend.domain.course.entity; + +import com.gdg.backend.domain.attendence.entity.Attendance; +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.mapping.ClassDocument; +import jakarta.persistence.*; +import lombok.Getter; + +import java.util.List; + +@Entity +@Getter +public class Course { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; // ๊ฐ•์˜ ์ด๋ฆ„ + + @ManyToOne + @JoinColumn(name = "classroom_id") + private Classroom classroom; + + @OneToMany(mappedBy = "course") + private List attendances; + + @OneToMany(mappedBy = "course") + private List classDocuments; +} + diff --git a/src/main/java/com/gdg/backend/domain/document/entity/Document.java b/src/main/java/com/gdg/backend/domain/document/entity/Document.java new file mode 100644 index 0000000..3cb52bb --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -0,0 +1,52 @@ +package com.gdg.backend.domain.document.entity; + +import com.gdg.backend.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Document extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Setter + private String content; + + @Setter + private Long version; + + /** + * content ์ˆ˜์ •์šฉ StringBuilder (content ์ง์ ‘ ์ˆ˜์ •์€ String์ด๋ฏ€๋กœ ์˜ค๋ž˜ ๊ฑธ๋ฆผ) + * !! ์ฃผ์˜: DB ์ €์žฅ ์ „์— syncContentBuilder() ๋“ฑ์œผ๋กœ contentBuilder -> content ๋™๊ธฐํ™” ํ•„์š” !! + * */ + @Transient + private StringBuilder contentBuilder; + + public StringBuilder getContentBuilder() { + if(contentBuilder == null) initContentBuilder(); + return contentBuilder; + } + + public void syncContentBuilder() { + if(contentBuilder != null) content = contentBuilder.toString(); + } + + // DB์—์„œ ๋กœ๋“œ ์‹œ content -> contentBuilder ์ดˆ๊ธฐํ™” + @PostLoad + public void initContentBuilder() { + if(content == null) content = ""; + this.contentBuilder = new StringBuilder(content); + System.out.println("DOCUMENT " + id + " ContentBuilder INITIATED (value=" + contentBuilder + ")"); + } + + @Override + public String toString() { + return String.format("DOCUMENT(id=%d, version=%d, contentBuilder=%s, ", id, version, contentBuilder.toString()) + "content=" + content + ")"; + } +} diff --git a/src/main/java/com/gdg/backend/domain/document/repository/DocumentRepository.java b/src/main/java/com/gdg/backend/domain/document/repository/DocumentRepository.java new file mode 100644 index 0000000..5fbb725 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/document/repository/DocumentRepository.java @@ -0,0 +1,14 @@ +package com.gdg.backend.domain.document.repository; + + +import com.gdg.backend.domain.document.entity.Document; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DocumentRepository extends JpaRepository { + Optional findById(Long id); +} diff --git a/src/main/java/com/gdg/backend/domain/enums/LanguageType.java b/src/main/java/com/gdg/backend/domain/enums/LanguageType.java new file mode 100644 index 0000000..d67183e --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/enums/LanguageType.java @@ -0,0 +1,16 @@ +package com.gdg.backend.domain.enums; + +public enum LanguageType { + C, + JAVA, + PYTHON; + + public static LanguageType fromString(String type) { + for (LanguageType languageType : LanguageType.values()) { + if (languageType.toString().equals(type)) { + return languageType; + } + } + return null; + } +} diff --git a/src/main/java/com/gdg/backend/domain/enums/MemberType.java b/src/main/java/com/gdg/backend/domain/enums/MemberType.java new file mode 100644 index 0000000..f08be5b --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/enums/MemberType.java @@ -0,0 +1,6 @@ +package com.gdg.backend.domain.enums; + +public enum MemberType { + TEACHER, + STUDENT; +} diff --git a/src/main/java/com/gdg/backend/domain/enums/OperationType.java b/src/main/java/com/gdg/backend/domain/enums/OperationType.java new file mode 100644 index 0000000..75bb277 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/enums/OperationType.java @@ -0,0 +1,9 @@ +package com.gdg.backend.domain.enums; + +public enum OperationType { + INSERT, + DELETE, + UPDATE, + CURSOR, + SYNC +} \ No newline at end of file diff --git a/src/main/java/com/gdg/backend/domain/helprequest/controller/HelpRequestController.java b/src/main/java/com/gdg/backend/domain/helprequest/controller/HelpRequestController.java new file mode 100644 index 0000000..5cf4896 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/helprequest/controller/HelpRequestController.java @@ -0,0 +1,29 @@ +package com.gdg.backend.domain.helprequest.controller; + +import com.gdg.backend.domain.helprequest.dto.HelpRequestDto; +import com.gdg.backend.domain.helprequest.service.HelpRequestService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class HelpRequestController { + private final SimpMessagingTemplate template; + private final HelpRequestService helpRequestService; + + @MessageMapping("/help/{classroomId}") + public void handleHelpRequest( + @Valid HelpRequestDto request, + @DestinationVariable("classroomId") Long classroomId + ) { + log.info("help request from student id {} : (classroomId={})", request.getUserId(), classroomId); + helpRequestService.handleHelpRequest(classroomId, request); + template.convertAndSend("/sub/ack", "ACK"); + } +} diff --git a/src/main/java/com/gdg/backend/domain/helprequest/dto/HelpRequestDto.java b/src/main/java/com/gdg/backend/domain/helprequest/dto/HelpRequestDto.java new file mode 100644 index 0000000..ba9a928 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/helprequest/dto/HelpRequestDto.java @@ -0,0 +1,12 @@ +package com.gdg.backend.domain.helprequest.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HelpRequestDto { + private Long userId; +} diff --git a/src/main/java/com/gdg/backend/domain/helprequest/dto/HelpRequestResponseDto.java b/src/main/java/com/gdg/backend/domain/helprequest/dto/HelpRequestResponseDto.java new file mode 100644 index 0000000..1e1d452 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/helprequest/dto/HelpRequestResponseDto.java @@ -0,0 +1,18 @@ +package com.gdg.backend.domain.helprequest.dto; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HelpRequestResponseDto { + private Long userId; + private Long documentId; + @Override + public String toString() { + return "HelpRequestResponseDto(userId=" + userId + ", documentId=" + documentId + ")"; + } +} diff --git a/src/main/java/com/gdg/backend/domain/helprequest/service/HelpRequestService.java b/src/main/java/com/gdg/backend/domain/helprequest/service/HelpRequestService.java new file mode 100644 index 0000000..ddd4022 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/helprequest/service/HelpRequestService.java @@ -0,0 +1,32 @@ +package com.gdg.backend.domain.helprequest.service; + +import com.gdg.backend.common.annotation.TrackExecutionTime; +import com.gdg.backend.domain.classroom.repository.ClassroomRepository; +import com.gdg.backend.domain.helprequest.dto.HelpRequestDto; +import com.gdg.backend.domain.helprequest.dto.HelpRequestResponseDto; +import com.gdg.backend.domain.mapping.IdeMember; +import com.gdg.backend.domain.mapping.repository.IdeMemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HelpRequestService { + private final SimpMessagingTemplate template; + private final IdeMemberRepository ideMemberRepository; + private final ClassroomRepository classroomRepository; + + @TrackExecutionTime + public void handleHelpRequest(Long classroomId, HelpRequestDto helpRequest) { + // STOMP ์˜ˆ์™ธ์ฒ˜๋ฆฌ ์ •๋ฆฌ + if(!classroomRepository.existsById(classroomId)) throw new IllegalStateException("ํ•ด๋‹นํ•˜๋Š” id์˜ ๊ฐ•์˜์‹ค์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. (id=" + classroomId + ")"); + IdeMember ideMember = ideMemberRepository.findByClassroomIdAndMemberIdFetchJoinDocument(classroomId, helpRequest.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ํšŒ์›์€ ํ•ด๋‹น ๊ฐ•์˜์‹ค์— ์†ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. (userId=" + helpRequest.getUserId() + ", id=" + classroomId + ")")); + HelpRequestResponseDto response = new HelpRequestResponseDto(helpRequest.getUserId(), ideMember.getDocument().getId()); + log.info("broadcast help to 'sub/help/{}': {}", classroomId, response); + template.convertAndSend("/sub/help/" + classroomId, response); + } +} diff --git a/src/main/java/com/gdg/backend/domain/invitation/entity/Invitation.java b/src/main/java/com/gdg/backend/domain/invitation/entity/Invitation.java new file mode 100644 index 0000000..3a776b0 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/invitation/entity/Invitation.java @@ -0,0 +1,33 @@ +package com.gdg.backend.domain.invitation.entity; + +import com.gdg.backend.common.entity.BaseTimeEntity; +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class Invitation extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "classroom_id") + private Classroom classroom; + + public Invitation(Member member, Classroom classroom) { + super(); + this.member = member; + this.classroom = classroom; + } +} diff --git a/src/main/java/com/gdg/backend/domain/invitation/repository/InvitationRepository.java b/src/main/java/com/gdg/backend/domain/invitation/repository/InvitationRepository.java new file mode 100644 index 0000000..b5b69f4 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/invitation/repository/InvitationRepository.java @@ -0,0 +1,18 @@ +package com.gdg.backend.domain.invitation.repository; + +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.invitation.entity.Invitation; +import com.gdg.backend.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface InvitationRepository extends JpaRepository { + + List findAllByMember(Member member); + + List findAllByClassroom(Classroom classroom); + + Boolean existsByMemberAndClassroom(Member member, Classroom classroom); + +} diff --git a/src/main/java/com/gdg/backend/domain/mapping/ClassDocument.java b/src/main/java/com/gdg/backend/domain/mapping/ClassDocument.java new file mode 100644 index 0000000..bdec5cf --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/mapping/ClassDocument.java @@ -0,0 +1,21 @@ +package com.gdg.backend.domain.mapping; + +import com.gdg.backend.domain.course.entity.Course; +import com.gdg.backend.domain.document.entity.Document; +import jakarta.persistence.*; + +@Entity +public class ClassDocument { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "class_id") + private Course course; + + @ManyToOne + @JoinColumn(name = "document_id") + private Document document; +} diff --git a/src/main/java/com/gdg/backend/domain/mapping/IdeMember.java b/src/main/java/com/gdg/backend/domain/mapping/IdeMember.java new file mode 100644 index 0000000..d5d4f7c --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/mapping/IdeMember.java @@ -0,0 +1,36 @@ +package com.gdg.backend.domain.mapping; + +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class IdeMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "document_id") + private Document document; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "classroom_id") + private Classroom classroom; + + public IdeMember(Document document, Member member, Classroom classroom) { + this.document = document; + this.member = member; + this.classroom = classroom; + } +} diff --git a/src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java b/src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java new file mode 100644 index 0000000..6377613 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java @@ -0,0 +1,23 @@ +package com.gdg.backend.domain.mapping.repository; + +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.mapping.IdeMember; +import com.gdg.backend.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + + +import java.util.Optional; + +public interface IdeMemberRepository extends JpaRepository { + + Optional findByMember(Member member); + + Optional findByMemberAndClassroom(Member member, Classroom classroom); + + Optional findByDocument(Document document); + + @Query("SELECT i FROM IdeMember i JOIN FETCH i.document WHERE i.classroom.id = :classroomId and i.member.id = :memberId") + Optional findByClassroomIdAndMemberIdFetchJoinDocument(Long classroomId, Long memberId); +} \ No newline at end of file diff --git a/src/main/java/com/gdg/backend/domain/member/controller/MemberController.java b/src/main/java/com/gdg/backend/domain/member/controller/MemberController.java new file mode 100644 index 0000000..0b90d9a --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/controller/MemberController.java @@ -0,0 +1,47 @@ +package com.gdg.backend.domain.member.controller; + +import com.gdg.backend.common.annotation.AuthUser; +import com.gdg.backend.common.response.ApiResponse; +import com.gdg.backend.domain.member.dto.SignInRequestDto; +import com.gdg.backend.domain.member.dto.SignInResponseDto; +import com.gdg.backend.domain.member.dto.SignUpRequestDto; +import com.gdg.backend.domain.member.dto.SignUpResponseDto; +import com.gdg.backend.domain.member.entity.Member; +import com.gdg.backend.domain.member.entity.Student; +import com.gdg.backend.domain.member.service.MemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +@Tag(name = "๋ฉค๋ฒ„ ๊ด€๋ จ API", description = "๋ฉค๋ฒ„ ๊ด€๋ จ API์ž…๋‹ˆ๋‹ค") +public class MemberController { + + private final MemberService memberService; + + @PostMapping("/sign-up") + @Operation(summary = "ํšŒ์›๊ฐ€์ž…") + public ApiResponse signupStudent(@RequestBody @Valid SignUpRequestDto signUpRequestDto) { + return ApiResponse.onSuccess(memberService.register(signUpRequestDto)); + } + + @PostMapping("/sign-in") + @Operation(summary = "๋กœ๊ทธ์ธ") + public ApiResponse signupStudent(@RequestBody @Valid SignInRequestDto signInRequestDto) { + return ApiResponse.onSuccess(memberService.signIn(signInRequestDto)); + } + + @GetMapping("/myprofile") + @Operation(summary = "๋Œ€์‹œ๋ณด๋“œ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ") + public ApiResponse getDashboardInfo(@AuthUser Member member) { + + System.out.println(member.getUsername()); + + return ApiResponse.onSuccess(memberService.getDashboardInfo(member)); + } +} diff --git a/src/main/java/com/gdg/backend/domain/member/dto/CourseInfo.java b/src/main/java/com/gdg/backend/domain/member/dto/CourseInfo.java new file mode 100644 index 0000000..4946ed4 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/dto/CourseInfo.java @@ -0,0 +1,26 @@ +package com.gdg.backend.domain.member.dto; + +import lombok.Getter; +import lombok.Setter; +import lombok.AllArgsConstructor; + +public class CourseInfo { + + @Getter + @Setter + @AllArgsConstructor + public static class TeacherCourseInfo { + private String courseCode; + private String courseName; + private Long courseId; + } + + @Getter + @Setter + @AllArgsConstructor + public static class StudentCourseInfo { + private String teacherName; + private String courseName; + private Long courseId; + } +} diff --git a/src/main/java/com/gdg/backend/domain/member/dto/DashBoardInfoDto.java b/src/main/java/com/gdg/backend/domain/member/dto/DashBoardInfoDto.java new file mode 100644 index 0000000..c8c04eb --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/dto/DashBoardInfoDto.java @@ -0,0 +1,26 @@ +package com.gdg.backend.domain.member.dto; + +import lombok.Getter; +import lombok.Setter; +import lombok.AllArgsConstructor; + +import java.util.List; + +public class DashBoardInfoDto { + + @Getter + @Setter + @AllArgsConstructor // ๋ชจ๋“  ํ•„๋“œ๊ฐ€ ์žˆ๋Š” ์ƒ์„ฑ์ž ์ž๋™ ์ƒ์„ฑ + public static class StudentDashBoardInfoDto { + private String studentName; + private List studentCourseInfoList; + } + + @Getter + @Setter + @AllArgsConstructor + public static class TeacherDashBoardInfoDto { + private String teacherName; + private List teacherCourseInfoList; + } +} diff --git a/src/main/java/com/gdg/backend/domain/member/dto/SignInRequestDto.java b/src/main/java/com/gdg/backend/domain/member/dto/SignInRequestDto.java new file mode 100644 index 0000000..eb9970f --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/dto/SignInRequestDto.java @@ -0,0 +1,23 @@ +package com.gdg.backend.domain.member.dto; + +import com.gdg.backend.domain.enums.MemberType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + + +@Getter +@Setter +@RequiredArgsConstructor +public class SignInRequestDto { + @NotBlank(message = "User ID cannot be blank") + @Size(min = 5, max = 20, message = "User ID must be between 5 and 20 characters") + private String userId; + + @NotBlank(message = "Password cannot be blank") + @Size(min = 8, max = 20, message = "Password must be between 8 and 20 characters") + private String password; +} diff --git a/src/main/java/com/gdg/backend/domain/member/dto/SignInResponseDto.java b/src/main/java/com/gdg/backend/domain/member/dto/SignInResponseDto.java new file mode 100644 index 0000000..ff07c2f --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/dto/SignInResponseDto.java @@ -0,0 +1,14 @@ +package com.gdg.backend.domain.member.dto; + +import lombok.*; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class SignInResponseDto { + private String username; + private String userId; + private String token; +} diff --git a/src/main/java/com/gdg/backend/domain/member/dto/SignUpRequestDto.java b/src/main/java/com/gdg/backend/domain/member/dto/SignUpRequestDto.java new file mode 100644 index 0000000..05bfca6 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/dto/SignUpRequestDto.java @@ -0,0 +1,32 @@ +package com.gdg.backend.domain.member.dto; + +import com.gdg.backend.domain.enums.MemberType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + + +@Getter +@Setter +@RequiredArgsConstructor +public class SignUpRequestDto { + + @NotBlank(message = "Username cannot be blank") + @Size(min = 3, max = 10, message = "Username must be between 3 and 10 characters") + private String username; + + @NotBlank(message = "User ID cannot be blank") + @Size(min = 5, max = 20, message = "User ID must be between 5 and 20 characters") + private String userId; + + @NotBlank(message = "Password cannot be blank") + @Size(min = 8, max = 20, message = "Password must be between 8 and 20 characters") + private String password; + + @NotNull(message = "Member type is required") + private MemberType memberType; +} diff --git a/src/main/java/com/gdg/backend/domain/member/dto/SignUpResponseDto.java b/src/main/java/com/gdg/backend/domain/member/dto/SignUpResponseDto.java new file mode 100644 index 0000000..b3f8d3c --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/dto/SignUpResponseDto.java @@ -0,0 +1,14 @@ +package com.gdg.backend.domain.member.dto; + +import com.gdg.backend.domain.enums.MemberType; +import lombok.*; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class SignUpResponseDto { + private String username; + private String userId; +} diff --git a/src/main/java/com/gdg/backend/domain/member/entity/Member.java b/src/main/java/com/gdg/backend/domain/member/entity/Member.java new file mode 100644 index 0000000..e3aa469 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/entity/Member.java @@ -0,0 +1,29 @@ +package com.gdg.backend.domain.member.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Inheritance(strategy = InheritanceType.JOINED) +@Getter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public abstract class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String username; + + @Column(nullable = false) + private String loginId; + + @Column(nullable = false) + private String password; +} diff --git a/src/main/java/com/gdg/backend/domain/member/entity/Student.java b/src/main/java/com/gdg/backend/domain/member/entity/Student.java new file mode 100644 index 0000000..f1ed80d --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/entity/Student.java @@ -0,0 +1,15 @@ +package com.gdg.backend.domain.member.entity; + +import jakarta.persistence.Entity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.experimental.SuperBuilder; + +@Entity +@Getter +@SuperBuilder +@AllArgsConstructor +public class Student extends Member { + +} diff --git a/src/main/java/com/gdg/backend/domain/member/entity/Teacher.java b/src/main/java/com/gdg/backend/domain/member/entity/Teacher.java new file mode 100644 index 0000000..442a110 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/entity/Teacher.java @@ -0,0 +1,15 @@ +package com.gdg.backend.domain.member.entity; + +import jakarta.persistence.Entity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.experimental.SuperBuilder; + +@Entity +@Getter +@SuperBuilder +@AllArgsConstructor +public class Teacher extends Member { + +} diff --git a/src/main/java/com/gdg/backend/domain/member/entity/UserPrincipal.java b/src/main/java/com/gdg/backend/domain/member/entity/UserPrincipal.java new file mode 100644 index 0000000..39f3198 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/entity/UserPrincipal.java @@ -0,0 +1,56 @@ +package com.gdg.backend.domain.member.entity; + +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; + +public class UserPrincipal implements UserDetails { + + private final Member member; + + public UserPrincipal(Member member) { + this.member = member; + } + + @Override + public Collection getAuthorities() { + // ๋งŒ์•ฝ ํšŒ์›์˜ ์—ญํ• (role) ์ •๋ณด๊ฐ€ ์žˆ๋‹ค๋ฉด ์ด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์ฒ˜๋ฆฌ + // ์˜ˆ๋ฅผ ๋“ค์–ด, ๋ชจ๋“  Student๋Š” "ROLE_STUDENT" ๊ถŒํ•œ์„ ๊ฐ€์ง„๋‹ค๊ณ  ๊ฐ€์ • + return List.of(new SimpleGrantedAuthority("ROLE_STUDENT")); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getUsername(); + } + + // ์•„๋ž˜ ๋ฉ”์„œ๋“œ๋“ค์€ ์‹ค์ œ ์„œ๋น„์Šค ์ •์ฑ…์— ๋งž๊ฒŒ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค. + @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/gdg/backend/domain/member/repository/MemberRepository.java b/src/main/java/com/gdg/backend/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..7a0e3d8 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/repository/MemberRepository.java @@ -0,0 +1,11 @@ +package com.gdg.backend.domain.member.repository; + +import com.gdg.backend.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByLoginId(String id); + Boolean existsByLoginId(String id); +} diff --git a/src/main/java/com/gdg/backend/domain/member/repository/StudentRepository.java b/src/main/java/com/gdg/backend/domain/member/repository/StudentRepository.java new file mode 100644 index 0000000..7415497 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/repository/StudentRepository.java @@ -0,0 +1,14 @@ +package com.gdg.backend.domain.member.repository; + +import com.gdg.backend.domain.member.entity.Student; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + + +public interface StudentRepository extends JpaRepository { + + Optional findById(@Param("id") Long id); + +} diff --git a/src/main/java/com/gdg/backend/domain/member/repository/TeacherRepository.java b/src/main/java/com/gdg/backend/domain/member/repository/TeacherRepository.java new file mode 100644 index 0000000..762b957 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/repository/TeacherRepository.java @@ -0,0 +1,12 @@ +package com.gdg.backend.domain.member.repository; + +import com.gdg.backend.domain.member.entity.Student; +import com.gdg.backend.domain.member.entity.Teacher; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + + +public interface TeacherRepository extends JpaRepository { +} diff --git a/src/main/java/com/gdg/backend/domain/member/service/MemberService.java b/src/main/java/com/gdg/backend/domain/member/service/MemberService.java new file mode 100644 index 0000000..2a9538a --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberService.java @@ -0,0 +1,13 @@ +package com.gdg.backend.domain.member.service; + +import com.gdg.backend.domain.member.dto.SignInRequestDto; +import com.gdg.backend.domain.member.dto.SignInResponseDto; +import com.gdg.backend.domain.member.dto.SignUpRequestDto; +import com.gdg.backend.domain.member.dto.SignUpResponseDto; +import com.gdg.backend.domain.member.entity.Member; + +public interface MemberService { + SignUpResponseDto register(SignUpRequestDto signUpRequestDto); + SignInResponseDto signIn(SignInRequestDto signInRequestDto); + Object getDashboardInfo(Member member); +} diff --git a/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java b/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java new file mode 100644 index 0000000..d91315f --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java @@ -0,0 +1,142 @@ +package com.gdg.backend.domain.member.service; + +import com.gdg.backend.common.exception.handler.GeneralHandler; +import com.gdg.backend.common.jwt.CustomPasswordEncoder; +import com.gdg.backend.common.jwt.JwtTokenProvider; +import com.gdg.backend.common.response.status.ErrorCode; +import com.gdg.backend.domain.classroom.entity.Classroom; +import com.gdg.backend.domain.classroom.repository.ClassroomRepository; +import com.gdg.backend.domain.invitation.entity.Invitation; +import com.gdg.backend.domain.member.dto.*; +import com.gdg.backend.domain.member.entity.Member; +import com.gdg.backend.domain.member.entity.Student; +import com.gdg.backend.domain.member.entity.Teacher; +import com.gdg.backend.domain.invitation.repository.InvitationRepository; +import com.gdg.backend.domain.member.repository.MemberRepository; +import com.gdg.backend.domain.member.repository.StudentRepository; +import com.gdg.backend.domain.member.repository.TeacherRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class MemberServiceImpl implements MemberService { + + private final StudentRepository studentRepository; + private final TeacherRepository teacherRepository; + private final MemberRepository memberRepository; + private final CustomPasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final InvitationRepository invitationRepository; + private final ClassroomRepository classroomRepository; + + @Override + public SignUpResponseDto register(SignUpRequestDto signUpRequestDto) { + + log.info("์ž…๋ ฅ ๋ฐ›์€ ์œ ์ € ์ด๋ฆ„ : {}", signUpRequestDto.getUsername()); + log.info("์ž…๋ ฅ ๋ฐ›์€ ์œ ์ € ์•„์ด๋”” : {}",signUpRequestDto.getUserId()); + + if(memberRepository.existsByLoginId(signUpRequestDto.getUserId())){ + throw new GeneralHandler(ErrorCode.EMAIL_ALREADY_EXIST); + } + + return switch (signUpRequestDto.getMemberType()) { + case STUDENT -> { + Student student = Student.builder() + .username(signUpRequestDto.getUsername()) + .loginId(signUpRequestDto.getUserId()) + .password(passwordEncoder.encode(signUpRequestDto.getPassword())) + .build(); + + studentRepository.save(student); + + yield SignUpResponseDto.builder() + .userId(student.getLoginId()) + .username(student.getUsername()) + .build(); + } + case TEACHER -> { + Teacher teacher = Teacher.builder() + .username(signUpRequestDto.getUsername()) + .loginId(signUpRequestDto.getUserId()) + .password(passwordEncoder.encode(signUpRequestDto.getPassword())) + .build(); + + teacherRepository.save(teacher); + + yield SignUpResponseDto.builder() + .userId(teacher.getLoginId()) + .username(teacher.getUsername()) + .build(); + } + }; + } + + @Override + public SignInResponseDto signIn(SignInRequestDto signInRequestDto) { + + String id = signInRequestDto.getUserId(); + String password = signInRequestDto.getPassword(); + + log.info("[getSignInResult] signDataHandler ๋กœ ํšŒ์› ์ •๋ณด ์š”์ฒญ"); + + Member member = memberRepository.findByLoginId(id).orElseThrow(()-> new GeneralHandler(ErrorCode.MEMBER_NOT_FOUND)); + + log.info("[getSignInResult] Id : {}", id); + + log.info("[getSignInResult] ํŒจ์Šค์›Œ๋“œ ๋น„๊ต ์ˆ˜ํ–‰"); + if (!passwordEncoder.matches(password, member.getPassword())) { + throw new GeneralHandler(ErrorCode.MEMBER_LOGIN_FAILURE); + } + log.info("[getSignInResult] ํŒจ์Šค์›Œ๋“œ ์ผ์น˜"); + + log.info("[getSignInResult] SignInResultDto ๊ฐ์ฒด ์ƒ์„ฑ"); + SignInResponseDto signInResultDto = SignInResponseDto.builder() + .userId(id) + .username(member.getUsername()) + .token(jwtTokenProvider.generateToken(member.getId())) + .build(); + + log.info("[getSignInResult] SignInResultDto ๊ฐ์ฒด์— ๊ฐ’ ์ฃผ์ž…"); + + return signInResultDto; + } + + @Override + public Object getDashboardInfo(Member member) { + + log.info(member.getUsername()); + + if (member instanceof Student) { + + List invitations = invitationRepository.findAllByMember(member); + + List studentCourseInfos = new java.util.ArrayList<>(List.of()); + + invitations.forEach(invitation -> { + studentCourseInfos.add(new CourseInfo.StudentCourseInfo(invitation.getClassroom().getTeacher().getUsername(), invitation.getClassroom().getName(), invitation.getClassroom().getId())); + }); + + return new DashBoardInfoDto.StudentDashBoardInfoDto(member.getUsername(), studentCourseInfos); + + } else if (member instanceof Teacher) { + + List classrooms = classroomRepository.findAllByTeacher((Teacher) member); + + List teacherCourseInfos = new java.util.ArrayList<>(List.of()); + + classrooms.forEach(classroom -> { + teacherCourseInfos.add(new CourseInfo.TeacherCourseInfo(classroom.getInvitationCode(), classroom.getName(), classroom.getId())); + }); + + return new DashBoardInfoDto.TeacherDashBoardInfoDto(member.getUsername(), teacherCourseInfos); + + } else { + throw new IllegalArgumentException("Unknown member type"); + } + } +} diff --git a/src/main/java/com/gdg/backend/domain/notice/entity/Notice.java b/src/main/java/com/gdg/backend/domain/notice/entity/Notice.java new file mode 100644 index 0000000..809df14 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/notice/entity/Notice.java @@ -0,0 +1,22 @@ +package com.gdg.backend.domain.notice.entity; + +import com.gdg.backend.common.entity.BaseTimeEntity; +import com.gdg.backend.domain.classroom.entity.Classroom; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +public class Notice extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "classroom_id") + private Classroom classroom; + + private String title; + + private String content; +} diff --git a/src/main/java/com/gdg/backend/domain/notice/repository/NoticeRepository.java b/src/main/java/com/gdg/backend/domain/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..8b66ca1 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/notice/repository/NoticeRepository.java @@ -0,0 +1,7 @@ +package com.gdg.backend.domain.notice.repository; + +import com.gdg.backend.domain.notice.entity.Notice; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NoticeRepository extends JpaRepository { +} diff --git a/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java new file mode 100644 index 0000000..5eae34c --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java @@ -0,0 +1,33 @@ +package com.gdg.backend.domain.operation.controller; + +import com.gdg.backend.domain.operation.dto.OperationRequestDto; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +import java.util.concurrent.BlockingQueue; + +@Slf4j +@Controller +public class WebsocketEventController { + private final BlockingQueue operationQueue; + private final SimpMessagingTemplate template; + + public WebsocketEventController( + BlockingQueue operationQueue, + SimpMessagingTemplate template + ){ + this.operationQueue = operationQueue; + this.template = template; + } + + /** ํด๋ผ์ด์–ธํŠธ์˜ ๋ฌธ์„œ ํŽธ์ง‘ ์š”์ฒญ์„ ๋ฉ”์‹œ์ง€ ํ์— push */ + @MessageMapping("/edit") + public void handleEditOperation(@Valid OperationRequestDto operation) throws InterruptedException { + operationQueue.put(operation); + log.info("[ENQUEUE] {}", operation); + template.convertAndSend("/sub/ack/" + operation.getDocumentId(), "ACK"); + } +} diff --git a/src/main/java/com/gdg/backend/domain/operation/dto/OperationAck.java b/src/main/java/com/gdg/backend/domain/operation/dto/OperationAck.java new file mode 100644 index 0000000..27d5d1b --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/dto/OperationAck.java @@ -0,0 +1,15 @@ +package com.gdg.backend.domain.operation.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์„œ๋ฒ„์—๊ฒŒ ๋ณด๋‚ด๋Š” ํ™•์ธ ๋ฉ”์‹œ์ง€ */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OperationAck { + private Long documentId; + private Long version; +} diff --git a/src/main/java/com/gdg/backend/domain/operation/dto/OperationRequestDto.java b/src/main/java/com/gdg/backend/domain/operation/dto/OperationRequestDto.java new file mode 100644 index 0000000..c37f9e1 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/dto/OperationRequestDto.java @@ -0,0 +1,41 @@ +package com.gdg.backend.domain.operation.dto; + +import com.gdg.backend.domain.enums.OperationType; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OperationRequestDto { + @NotNull(message = "operation์€ null์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + OperationType operation; + + @NotNull(message = "documentId๋Š” null์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + Long documentId; + + String insertContent; + + Integer deleteLength; + +// @NotNull(message = "position์€ null์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + Long position; + + Long baseVersion; + + Long userId; // todo ์ถ”ํ›„์— jwt ํ—ค๋”์—์„œ ์œ ์ € ์ •๋ณด ๊ฐ€์ ธ์˜ค๋Š” ๊ฑธ๋กœ ๋ฐ”๊พธ๊ธฐ + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(operation).append(" "); + if(operation.equals(OperationType.INSERT)) sb.append("'" + insertContent + "' "); + if(operation.equals(OperationType.DELETE)) sb.append(deleteLength + " "); + sb.append("pos=").append(position).append(" ") + .append(String.format("docId=%d, version=%d, userId=%d", documentId, baseVersion, userId)); + return sb.toString(); + } +} diff --git a/src/main/java/com/gdg/backend/domain/operation/dto/OperationResponseDto.java b/src/main/java/com/gdg/backend/domain/operation/dto/OperationResponseDto.java new file mode 100644 index 0000000..996243c --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/dto/OperationResponseDto.java @@ -0,0 +1,43 @@ +package com.gdg.backend.domain.operation.dto; + + +import com.gdg.backend.domain.enums.OperationType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OperationResponseDto { + OperationType operation; + Long documentId; + String insertContent; + Integer deleteLength; + Long position; + Long version; + Long userId; + + public static OperationResponseDto of(OperationRequestDto request) { + OperationResponseDto response = new OperationResponseDto(); + response.setOperation(request.getOperation()); + response.setDocumentId(request.getDocumentId()); + response.setInsertContent(request.getInsertContent()); + response.setDeleteLength(request.getDeleteLength()); + response.setPosition(request.getPosition()); + response.setVersion(request.getBaseVersion()); + response.setUserId(request.getUserId()); + return response; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(operation).append(" "); + if(operation.equals(OperationType.INSERT)) sb.append("'" + insertContent + "' "); + if(operation.equals(OperationType.DELETE)) sb.append(deleteLength + " "); + sb.append("pos=").append(position).append(" ") + .append(String.format("docId=%d, version=%d, userId=%d", documentId, version, userId)); + return sb.toString(); + } +} diff --git a/src/main/java/com/gdg/backend/domain/operation/dto/SyncOperationResponseDto.java b/src/main/java/com/gdg/backend/domain/operation/dto/SyncOperationResponseDto.java new file mode 100644 index 0000000..ef0df4e --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/dto/SyncOperationResponseDto.java @@ -0,0 +1,16 @@ +package com.gdg.backend.domain.operation.dto; + +import com.gdg.backend.domain.enums.OperationType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SyncOperationResponseDto { + OperationType operation; + Long userId; + Long version; + String content; +} diff --git a/src/main/java/com/gdg/backend/domain/operation/entity/Operation.java b/src/main/java/com/gdg/backend/domain/operation/entity/Operation.java new file mode 100644 index 0000000..5e4c8ad --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/entity/Operation.java @@ -0,0 +1,45 @@ +package com.gdg.backend.domain.operation.entity; + +import com.gdg.backend.common.entity.BaseTimeEntity; +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.enums.OperationType; +import com.gdg.backend.domain.member.entity.Member; +import com.gdg.backend.domain.operation.dto.OperationResponseDto; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.apache.catalina.User; + + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Operation extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private OperationType operation; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_id") + private Document document; + + private Long position; // ์ˆ˜์ •๋œ ๋‚ด์šฉ์˜ ์ธ๋ฑ์Šค + + private String insertContent; // ์‚ฝ์ž…๋œ ํ…์ŠคํŠธ + + private Integer deleteLength; + + private Long version; // ์ ์šฉ ์ˆœ์„œ + +} diff --git a/src/main/java/com/gdg/backend/domain/operation/repository/OperationRepository.java b/src/main/java/com/gdg/backend/domain/operation/repository/OperationRepository.java new file mode 100644 index 0000000..ef34a63 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/repository/OperationRepository.java @@ -0,0 +1,19 @@ +package com.gdg.backend.domain.operation.repository; + +import com.gdg.backend.domain.operation.entity.Operation; +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 OperationRepository extends JpaRepository { + + @Query("SELECT o FROM Operation o JOIN FETCH o.member JOIN FETCH o.document WHERE o.document.id = :documentId AND o.version > :baseVersion") + List findByDocumentIdAndVersionGreaterThanFetchJoin(@Param("documentId") Long documentId, @Param("baseVersion") Long version); + + void deleteByDocumentId(Long testDocId); +} + diff --git a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java new file mode 100644 index 0000000..9929d57 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -0,0 +1,253 @@ +package com.gdg.backend.domain.operation.service; + +import com.gdg.backend.common.exception.handler.GeneralHandler; +import com.gdg.backend.common.response.status.ErrorCode; +import com.gdg.backend.common.annotation.TrackExecutionTime; +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.document.repository.DocumentRepository; +import com.gdg.backend.domain.enums.OperationType; +import com.gdg.backend.domain.member.entity.Member; +import com.gdg.backend.domain.member.repository.MemberRepository; +import com.gdg.backend.domain.operation.dto.OperationRequestDto; +import com.gdg.backend.domain.operation.dto.OperationResponseDto; +import com.gdg.backend.domain.operation.dto.SyncOperationResponseDto; +import com.gdg.backend.domain.operation.entity.Operation; +import com.gdg.backend.domain.operation.repository.OperationRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + + +/** OperationType ํ์—์„œ ์ฃผ๊ธฐ์ ์œผ๋กœ ์ด๋ฒคํŠธ๋ฅผ ๊ฐ€์ ธ์™€ ์ฒ˜๋ฆฌํ•˜๋Š” ํด๋ž˜์Šค */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OperationQueueProcessor { + + private final DocumentRepository documentRepository; + private final OperationRepository operationRepository; + private final MemberRepository memberRepository; + private final BlockingQueue operationQueue; + private final SimpMessagingTemplate template; + private final ConcurrentHashMap documentVersions = new ConcurrentHashMap<>(); + + // ๋ฌธ์„œ ์ƒํƒœ ์บ์‹ฑ + // - todo ๋ฌธ์„œ ๋งŽ์•„์ง€๋ฉด OutOfMemory ๋ฐœ์ƒ ๊ฐ€๋Šฅ -> ์ถ”ํ›„์— LRU๋‚˜ TTL ์„ค์ • + private final ConcurrentHashMap documentCache = new ConcurrentHashMap<>(); + + private final Set dirtyDocuments = new HashSet<>(); // ๋ณ€๊ฒฝ๋œ Document ์ถ”์  (์ฃผ๊ธฐ์ ์œผ๋กœ ์ €์žฅ) + + @PostConstruct + public void startProcessing() { + // ๋ณ„๋„ ์Šค๋ ˆ๋“œ์—์„œ ํ๋ฅผ polling ํ•˜์—ฌ ์ฒ˜๋ฆฌ + new Thread(() -> { + while(true) { + try { + OperationRequestDto operation = operationQueue.take(); + processOperation(operation); + } catch (InterruptedException e) { + log.error("QUEUE PROCESSOR THREAD INTERRUPTED: {}", e.getMessage(), e); + break; + } catch (Exception e) { + log.error("QUEUE PROCESSOR UNCAUGHT EXCEPTION: {}", e.getMessage(), e); + } + } + }).start(); + } + + @PostConstruct + public void postConstructJob() { + fillDocumentPool(); + fillDocumentVersionPool(); + } + + /** DB์—์„œ Document fetchํ•ด์„œ ๋ฉ”๋ชจ๋ฆฌ๋กœ ๊ฐ€์ ธ์˜ด */ + public void fillDocumentPool() { + List documents = documentRepository.findAll(); + documents.forEach(doc -> documentCache.put(doc.getId(), doc)); + log.info("FILLED DOCUMENT POOL : {}", documentCache); + } + + /** DB์— ์กด์žฌํ•˜๋Š” Document version pool ์ถ”์  (์ธ๋ฉ”๋ชจ๋ฆฌ๋ผ์„œ ์„œ๋ฒ„ ๊ป๋‹คํ‚ค๋ฉด ์‚ฌ๋ผ์ง€๋‹ˆ๊นŒ..) */ + public void fillDocumentVersionPool () { + List documents = documentRepository.findAll(); + documents.stream().forEach(document -> { + if(document.getVersion() > documentVersions.getOrDefault(document.getId(), new AtomicLong(-1)).get()) + documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); + }); + log.info("FILLED DOCUMENT VERSION POOL: {}", documentVersions); + } + + @TrackExecutionTime + public void processOperation(OperationRequestDto operation) { + Long docId = operation.getDocumentId(); + Long baseVersion = operation.getBaseVersion(); + Long opPosition = operation.getPosition(); + + // documentID ์—†๋Š” ๊ฒฝ์šฐ ์บ์‹œ ์—…๋ฐ์ดํŠธ + if(!documentCache.containsKey(docId)) { + Document toSave = documentRepository.findById(docId) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค.")); + documentCache.put(docId, toSave); + documentVersions.put(docId, new AtomicLong(toSave.getVersion())); + } + Document doc = documentCache.get(docId); + + // SYNC์ธ ๊ฒฝ์šฐ ๋”ฐ๋กœ ์ฒ˜๋ฆฌ + // - ํ˜„์žฌ ๋ฌธ์„œ ์ƒํƒœ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… + // - version์„ ๋†’์ด์ง€ ์•Š์Œ + // - ์ถ”ํ›„ ์ „๋žต ํŒจํ„ด ๋“ฑ์œผ๋กœ ์ถ”์ƒํ™” + if(operation.getOperation().equals(OperationType.SYNC)) { + log.info("[OPERATION] SYNC"); + String docContent = doc.getContentBuilder().toString(); + SyncOperationResponseDto response = new SyncOperationResponseDto(OperationType.SYNC, operation.getUserId(), documentVersions.get(docId).get(), docContent); + template.convertAndSend("/sub/edit/" + docId, response); + return; + } + + // ์ถ”ํ›„ ์ˆ˜์ • + if(opPosition == null) throw new IllegalStateException("opPosition์€ null์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + + // operation ์ถฉ๋Œ ์‹œ ๋ณ€ํ™˜ ์ฒ˜๋ฆฌ + // - operation์˜ baseVersion๊ณผ ์„œ๋ฒ„๊ฐ€ ์ถ”์ ํ•˜๋Š” version์„ ๋น„๊ต + // - ์ฐจ์ด๋‚˜๋Š” version๋งŒํผ position์„ ์—…๋ฐ์ดํŠธํ•œ๋‹ค (insert: position ์ฆ๊ฐ€ / delete: position ๊ฐ์†Œ) + // - ์ด์ „ version์˜ ์ด๋ฒคํŠธ ์ถ”์  ๋ฐฉ๋ฒ• + // - (1) DB์—์„œ ๊ฐ€์ ธ์˜จ๋‹ค -> ๊ตฌํ˜„์ด ์‰ฌ์šฐ๋‹ˆ๊นŒ ์ผ๋‹จ ์ด๊ฑธ๋กœ ๊ฐ + // - ๋Œ€์‹  DB ๊ฐ€์ ธ์˜ค๋Š” ์‹œ๊ฐ„์ด ๋„ˆ๋ฌด ์˜ค๋ž˜ ๊ฑธ๋ฆด ๊ฑฐ์ž„ + // - (2) ๋ฉ”๋ชจ๋ฆฌ์— ํ‚ตํ•œ๋‹ค -> ์–ผ๋งˆ๋‚˜ ํ‚ตํ• ์ง€ ์•Œ ์ˆ˜ ์—†์Œ (์ „๋ถ€ ํ‚ตํ•˜๋ฉด ๊ฒฐ๊ตญ OutOfMemory ๋œฐ๊ฑฐ์ž„) + // - ์—ฐ๊ฒฐ๋œ ํด๋ผ๋“ค์ด ์–ด๋А version๊นŒ์ง€ ๋ฐ›์•˜๋Š”์ง€ ์ถ”์ ํ•˜๋ฉด ๋ฉ”๋ชจ๋ฆฌ ํ• ๋‹น๋Ÿ‰ ์กฐ์ ˆ ๊ฐ€๋Šฅ + // - ํด๋ผ๊ฐ€ ์ „๋ถ€ version 11๊นŒ์ง€๋Š” ๋ฐ›์•˜๋‹ค -> version 10 ์ด์ƒ์€ ๋ฉ”๋ชจ๋ฆฌ์—์„œ ํ•ด์ œ + // - queue๋กœ ๊ตฌํ˜„ํ•ด์„œ, ํด๋ผ์ด์–ธํŠธ ACK ๋ฐ›์„ ์‹œ queue์—์„œ ์˜›๋‚  event pop / ์ƒˆ๋กœ์šด event ๋ฐ›์„ ์‹œ queue์— push + // -> ํด๋ผ์ด์–ธํŠธ ACK ์ถ”์  ๊ธฐ๋Šฅ ๊ตฌํ˜„ ๋˜๋ฉด (2)๋ฒˆ์œผ๋กœ ๊ฐˆ์•„ํƒ€๊ธฐ + try { + // ๋กœ๊ทธ ์ถœ๋ ฅ + log.info("[OPERATION]: {}", operation); + List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThanFetchJoin(docId, baseVersion); + for (Operation concurrentOp : concurrentOperations) { + // ๋ณธ์ธ์˜ Operation์ธ ๊ฒฝ์šฐ ์ถฉ๋Œ ์ฒ˜๋ฆฌ X + if(Objects.equals(concurrentOp.getMember().getId(), operation.getUserId())) + continue; + if(concurrentOp.getPosition() == null) continue; + if(concurrentOp.getOperation().equals(OperationType.INSERT) && concurrentOp.getPosition() <= opPosition) { + // ํ˜„์žฌ operation๋ณด๋‹ค ์•ž์— ์‚ฝ์ž…ํ•œ ๊ฒฝ์šฐ pos ์ฆ๊ฐ€ (๋“ฑํ˜ธ ํฌํ•จ) + if(concurrentOp.getInsertContent() == null) continue; + opPosition += concurrentOp.getInsertContent().length(); + } + else if (concurrentOp.getOperation().equals(OperationType.DELETE)) { + if(concurrentOp.getDeleteLength() == null) continue; + // ์ด๋ฏธ ์‚ญ์ œํ•œ ๋ฌธ์ž๋ฅผ ์‚ญ์ œํ•˜๋ ค๋Š” ๊ฒฝ์šฐ ์ž‘์—… ์ง„ํ–‰ X + long[] deleteRange = new long[]{concurrentOp.getPosition() - concurrentOp.getDeleteLength() + 1, concurrentOp.getPosition()}; + long[] currentDeleteRange = new long[]{opPosition - operation.getDeleteLength() + 1, opPosition}; + // ๋ฒ”์œ„๊ฐ€ ์™„์ „ํžˆ ๊ฒน์น˜๋Š” ๊ฒฝ์šฐ Operation์„ Dropํ•œ๋‹ค. (DB ์ €์žฅ์ด๋‚˜ ๋ฒ„์ „ ์—…๋ฐ์ดํŠธ๋„ ์ง„ํ–‰ํ•˜์ง€ ์•Š์Œ) + if (operation.getOperation().equals(OperationType.DELETE) && + deleteRange[0] <= currentDeleteRange[0] && currentDeleteRange[1] <= deleteRange[1]) { + log.info("- DROP OPERATION (index {} already deleted", opPosition); + return; + } + // ๋ฒ”์œ„๊ฐ€ ๋ถ€๋ถ„์ ์œผ๋กœ ๊ฒน์น˜๊ณ  ํ˜„์žฌ Operation์ด ๋” ์•ž ์ชฝ์ธ ๊ฒฝ์šฐ, pos์™€ deleteLength๋ฅผ ๊ฐ์†Œ์‹œํ‚จ๋‹ค. + else if (operation.getOperation().equals(OperationType.DELETE) && + currentDeleteRange[0] < deleteRange[0] && currentDeleteRange[1] <= deleteRange[1]) { + long delta = currentDeleteRange[1] - deleteRange[0]; + opPosition -= delta; + operation.setDeleteLength((int) (operation.getDeleteLength() - delta)); + } + // ๋ฒ”์œ„๊ฐ€ ๋ถ€๋ถ„์ ์œผ๋กœ ๊ฒน์น˜๊ณ  ํ˜„์žฌ Operation์ด ๋” ๋’ค ์ชฝ์ธ ๊ฒฝ์šฐ, deleteLength๋งŒ์„ ๊ฐ์†Œ์‹œํ‚จ๋‹ค. + else if (operation.getOperation().equals(OperationType.DELETE) && + deleteRange[0] <= currentDeleteRange[0] && deleteRange[1] < currentDeleteRange[1]) { + long delta = deleteRange[1] - currentDeleteRange[0]; + operation.setDeleteLength((int) (operation.getDeleteLength() - delta)); + } + // ๋ฒ”์œ„๊ฐ€ ๊ฒน์น˜์ง€ ์•Š๊ณ  ํ˜„์žฌ operation๋ณด๋‹ค ์•ž์„ ์‚ญ์ œํ•œ ๊ฒฝ์šฐ pos ๊ฐ์†Œ (๋“ฑํ˜ธ ๋ฏธํฌํ•จ) + else if(concurrentOp.getPosition() < opPosition) opPosition -= concurrentOp.getDeleteLength(); + } + } + + // ๋ฒ„์ „ ๋ถ€์—ฌ + OperationResponseDto response = OperationResponseDto.of(operation); + response.setPosition(opPosition); + response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); + + // ๋ฌธ์„œ ์ƒํƒœ ๊ฐฑ์‹  + int idx = Math.toIntExact(opPosition); + synchronized (doc) { + switch (operation.getOperation()) { + case INSERT -> doc.getContentBuilder().insert(idx, operation.getInsertContent()); + case DELETE -> doc.getContentBuilder().delete(idx - operation.getDeleteLength() + 1, idx + 1); + } + doc.setVersion(documentVersions.get(operation.getDocumentId()).get()); + } + dirtyDocuments.add(docId); + + // ๋กœ๊ทธ ์ถœ๋ ฅ + if(!Objects.equals(operation.getPosition(), response.getPosition())) { + log.info("- OPERATION TRANSFORMED: pos={}->{}", operation.getPosition(), response.getPosition()); + } + log.info("- saving operation: {}", response); + log.info("- current content: {}", doc.getContentBuilder().toString()); + + // Operation DB์— ์ €์žฅ && Document version ์—…๋ฐ์ดํŠธ + // - ๋™๊ธฐ ์ฒ˜๋ฆฌ vs ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ + // - todo ๋ฉ”๋ชจ๋ฆฌ์— Operation ์บ์‹ฑํ•˜๊ธฐ + // - Operation ํ ๋งŒ๋“ค์–ด์„œ ์บ์‹ฑํ•˜๊ธฐ (ํด๋ผ์ด์–ธํŠธ ACK์— ๋งž์ถฐ ๊ฐฑ์‹ ) + Member author = memberRepository.findById(operation.getUserId()) + .orElseGet(() -> { + log.info("- WARNING: member id {} doesn't exist", operation.getUserId()); + return null; + }); + operationRepository.save(Operation.builder() + .operation(response.getOperation()) + .document(doc) + .position(response.getPosition()) + .insertContent(response.getInsertContent()) + .deleteLength(response.getDeleteLength()) + .version(response.getVersion()) + .member(author) // todo + .build() + ); + + // ํด๋ผ์ด์–ธํŠธ์— ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ + template.convertAndSend("/sub/edit/" + docId, response); + } catch (Exception e) { + log.error("Exception while handling operation {} -> {}", operation, e.getMessage(), e); + } + } + + /** ์ฃผ๊ธฐ์ ์œผ๋กœ ๋ณ€๊ฒฝ๋œ ๋ฌธ์„œ ์ €์žฅ
+ * - DB ์“ฐ๊ธฐ๋Š” ๋„คํŠธ์›Œํฌ ์š”์ฒญ + ๋””์Šคํฌ I/O๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ ๋ฌด๊ฑฐ์›€ + * - DB ์ž‘์—…์€ processOperation์—์„œ ์ตœ๋Œ€ํ•œ ์ œ๊ฑฐํ•ด์„œ ์‹คํ–‰์‹œ๊ฐ„ ์ค„์—ฌ์„œ ์ง€์—ฐ์‹œ๊ฐ„ & ํŠธ๋žœ์žญ์…˜ ๋ถ€ํ•˜ ๊ฐ์†Œ + * - ์‹ค์‹œ๊ฐ„์„ฑ์€ documentCache๋กœ ์œ ์ง€ํ•˜๊ณ  ์ €์žฅ์€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์ฃผ๊ธฐ์ ์œผ๋กœ ์ง„ํ–‰ + * */ + @Scheduled(fixedRate = 10000) // 10์ดˆ๋งˆ๋‹ค ์‹คํ–‰ + public void scheduleSavingDirtyDocuments() { + if(!dirtyDocuments.isEmpty()) { + log.info("SAVING DIRTY DOCUMENTS (id=" + dirtyDocuments + ")"); + saveDirtyDocuments(); + } + } + + @Transactional + public void saveDirtyDocuments() { + for (Long docId : dirtyDocuments) { + Document cachedDoc = documentCache.get(docId); + if (cachedDoc != null) { + synchronized (cachedDoc) { + cachedDoc.syncContentBuilder(); + documentRepository.save(cachedDoc); + } + } + } + dirtyDocuments.clear(); + } +} diff --git a/src/main/resources/.gitkeep b/src/main/resources/.gitkeep new file mode 100644 index 0000000..9f12383 --- /dev/null +++ b/src/main/resources/.gitkeep @@ -0,0 +1 @@ +/resources directory placeholder \ No newline at end of file diff --git a/src/test/java/com/gdg/backend/BackendApplicationTests.java b/src/test/java/com/gdg/backend/BackendApplicationTests.java new file mode 100644 index 0000000..13e0df5 --- /dev/null +++ b/src/test/java/com/gdg/backend/BackendApplicationTests.java @@ -0,0 +1,13 @@ +package com.gdg.backend; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BackendApplicationTests { + + @Test + void contextLoads() { + } + +}