From 322a9b20e7ab9011eea1b77336456c136a5fea86 Mon Sep 17 00:00:00 2001 From: YamYamee Date: Thu, 6 Feb 2025 01:27:39 +0900 Subject: [PATCH 01/34] init commit --- .gitattributes | 3 + .gitignore | 37 +++ build.gradle | 39 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle | 1 + .../com/gdg/backend/BackendApplication.java | 16 ++ .../backend/common/entity/BaseTimeEntity.java | 27 ++ .../gdg/backend/domain/Enum/Operation.java | 7 + .../domain/attendence/entity/Attendance.java | 24 ++ .../backend/domain/build/entity/Build.java | 25 ++ .../domain/channel/entity/Channel.java | 4 + .../domain/classroom/entity/Classroom.java | 27 ++ .../backend/domain/course/entity/Course.java | 30 +++ .../domain/document/entity/Document.java | 16 ++ .../entity/DocumentOperation.java | 31 +++ .../domain/invitation/entity/Invitation.java | 22 ++ .../backend/domain/mapping/ClassDocument.java | 21 ++ .../backend/domain/member/entity/Member.java | 25 ++ .../backend/domain/member/entity/Student.java | 8 + .../backend/domain/notice/entity/Notice.java | 19 ++ src/main/resources/application.yml | 21 ++ .../gdg/backend/BackendApplicationTests.java | 13 + 25 files changed, 769 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/gdg/backend/BackendApplication.java create mode 100644 src/main/java/com/gdg/backend/common/entity/BaseTimeEntity.java create mode 100644 src/main/java/com/gdg/backend/domain/Enum/Operation.java create mode 100644 src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java create mode 100644 src/main/java/com/gdg/backend/domain/build/entity/Build.java create mode 100644 src/main/java/com/gdg/backend/domain/channel/entity/Channel.java create mode 100644 src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java create mode 100644 src/main/java/com/gdg/backend/domain/course/entity/Course.java create mode 100644 src/main/java/com/gdg/backend/domain/document/entity/Document.java create mode 100644 src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java create mode 100644 src/main/java/com/gdg/backend/domain/invitation/entity/Invitation.java create mode 100644 src/main/java/com/gdg/backend/domain/mapping/ClassDocument.java create mode 100644 src/main/java/com/gdg/backend/domain/member/entity/Member.java create mode 100644 src/main/java/com/gdg/backend/domain/member/entity/Student.java create mode 100644 src/main/java/com/gdg/backend/domain/notice/entity/Notice.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/gdg/backend/BackendApplicationTests.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..395eae9 --- /dev/null +++ b/build.gradle @@ -0,0 +1,39 @@ +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' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 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..36ca53b --- /dev/null +++ b/src/main/java/com/gdg/backend/BackendApplication.java @@ -0,0 +1,16 @@ +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; + +@SpringBootApplication +@EnableJpaAuditing +public class BackendApplication { + + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } + +} 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/domain/Enum/Operation.java b/src/main/java/com/gdg/backend/domain/Enum/Operation.java new file mode 100644 index 0000000..3c4fe85 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/Enum/Operation.java @@ -0,0 +1,7 @@ +package com.gdg.backend.domain.Enum; + +public enum Operation { + INSERT, + DELETE, + UPDATE +} \ No newline at end of file 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..caaddec --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java @@ -0,0 +1,24 @@ +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.*; + +@Entity +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/entity/Build.java b/src/main/java/com/gdg/backend/domain/build/entity/Build.java new file mode 100644 index 0000000..1402b00 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/build/entity/Build.java @@ -0,0 +1,25 @@ +package com.gdg.backend.domain.build.entity; + +import com.gdg.backend.common.entity.BaseTimeEntity; +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.member.entity.Member; +import jakarta.persistence.*; + +@Entity +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; + + private String result; +} + 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..82c18bb --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/channel/entity/Channel.java @@ -0,0 +1,4 @@ +package com.gdg.backend.domain.channel.entity; + +public class Channel { +} 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..44a4170 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java @@ -0,0 +1,27 @@ +package com.gdg.backend.domain.classroom.entity; + +import com.gdg.backend.domain.course.entity.Course; +import com.gdg.backend.domain.invitation.entity.Invitation; +import com.gdg.backend.domain.notice.entity.Notice; +import jakarta.persistence.*; + +import java.util.List; + +@Entity +public class Classroom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; // 강의실 이름 + + @OneToMany(mappedBy = "classroom") + private List courses; + + @OneToMany(mappedBy = "classroom") + private List notices; + + @OneToMany(mappedBy = "classroom") + private List invitations; +} 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..5323aea --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/course/entity/Course.java @@ -0,0 +1,30 @@ +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 java.util.List; + +@Entity +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..daababe --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -0,0 +1,16 @@ +package com.gdg.backend.domain.document.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class Document { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String content; +} diff --git a/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java b/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java new file mode 100644 index 0000000..1be25c8 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java @@ -0,0 +1,31 @@ +package com.gdg.backend.domain.documentoperation.entity; + +import com.gdg.backend.common.entity.BaseTimeEntity; +import com.gdg.backend.domain.Enum.Operation; +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.member.entity.Member; +import jakarta.persistence.*; + + + +@Entity +public class DocumentOperation extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private Operation operation; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "document_id") + private Document document; + + private Integer index; // 수정된 내용의 인덱스 + + private String content; // 삽입된 텍스트 +} 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..9c905d5 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/invitation/entity/Invitation.java @@ -0,0 +1,22 @@ +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.*; + +@Entity +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; + +} 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/member/entity/Member.java b/src/main/java/com/gdg/backend/domain/member/entity/Member.java new file mode 100644 index 0000000..1ea62e1 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/entity/Member.java @@ -0,0 +1,25 @@ +package com.gdg.backend.domain.member.entity; + +import jakarta.persistence.*; + +@Entity +@Inheritance(strategy = InheritanceType.JOINED) +public abstract class Member { + + @Id + @GeneratedValue + private Long id; + + @Column(nullable = false) + private String username; + + @Column(nullable = false) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String email; + +} 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..41c8d8c --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/entity/Student.java @@ -0,0 +1,8 @@ +package com.gdg.backend.domain.member.entity; + +import jakarta.persistence.Entity; + +@Entity +public class Student extends Member { + +} 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..6e45696 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/notice/entity/Notice.java @@ -0,0 +1,19 @@ +package com.gdg.backend.domain.notice.entity; + +import com.gdg.backend.domain.classroom.entity.Classroom; +import jakarta.persistence.*; + +@Entity +public class Notice { + @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/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..714cd7a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb # ??? ? H2 ?????? ?? + driverClassName: org.h2.Driver + username: sa + password: + hikari: + maximum-pool-size: 10 # ??? ? ?? ?? (??? ??) + + jpa: + hibernate: + ddl-auto: update # ???? ?? ??? ?? ??/???? + show-sql: true # ???? SQL? ??? ?? + properties: + hibernate: + format_sql: true # SQL ???? ?? ?? ?? + + h2: + console: + enabled: true # H2 ?? ??? + path: /h2-console # H2 ?? ?? 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() { + } + +} From 05fceca6c41c7eba2e6e83b031595e188013d0e6 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:22:15 +0900 Subject: [PATCH 02/34] =?UTF-8?q?[Chore]=20=EA=B9=83=ED=97=88=EB=B8=8C=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=93=B1=EB=A1=9D=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add: 템플릿 파일 추가 * rename: Enum 패키지 enums로 변경 --- .github/ISSUE_TEMPLATE/bug_issue_template.md | 14 ++++++++++ .../ISSUE_TEMPLATE/empty_issue_template.md | 7 +++++ .../ISSUE_TEMPLATE/general_issue_template.md | 16 ++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 26 +++++++++++++++++++ .../entity/DocumentOperation.java | 2 +- .../domain/{Enum => enums}/Operation.java | 2 +- 6 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_issue_template.md create mode 100644 .github/ISSUE_TEMPLATE/empty_issue_template.md create mode 100644 .github/ISSUE_TEMPLATE/general_issue_template.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md rename src/main/java/com/gdg/backend/domain/{Enum => enums}/Operation.java (61%) 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/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java b/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java index 1be25c8..4928fcf 100644 --- a/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java +++ b/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java @@ -1,7 +1,7 @@ package com.gdg.backend.domain.documentoperation.entity; import com.gdg.backend.common.entity.BaseTimeEntity; -import com.gdg.backend.domain.Enum.Operation; +import com.gdg.backend.domain.enums.Operation; import com.gdg.backend.domain.document.entity.Document; import com.gdg.backend.domain.member.entity.Member; import jakarta.persistence.*; diff --git a/src/main/java/com/gdg/backend/domain/Enum/Operation.java b/src/main/java/com/gdg/backend/domain/enums/Operation.java similarity index 61% rename from src/main/java/com/gdg/backend/domain/Enum/Operation.java rename to src/main/java/com/gdg/backend/domain/enums/Operation.java index 3c4fe85..1d7fa3c 100644 --- a/src/main/java/com/gdg/backend/domain/Enum/Operation.java +++ b/src/main/java/com/gdg/backend/domain/enums/Operation.java @@ -1,4 +1,4 @@ -package com.gdg.backend.domain.Enum; +package com.gdg.backend.domain.enums; public enum Operation { INSERT, From d81f8af7625d82068fecdf372745ef128c763673 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:22:28 +0900 Subject: [PATCH 03/34] =?UTF-8?q?[Chore]=20CICD=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add: cicd 스크립트 추가 * add: 테스트 스크립트 추가 * del: application.yml 삭제 * add: resources/ 더미 파일 추가 * add: 디펜던시 추가 * update: DocumentOperation index 필드명 수정 * update: dev_deploy 소스코드 업로드 step 오류 수정 * update: dev_deploy pr 조건 삭제 * update: dev_test --- .github/workflows/dev_deploy.yml | 106 ++++++++++++++++++ .github/workflows/dev_test.yml | 55 +++++++++ .gitignore | 4 + build.gradle | 3 + .../entity/DocumentOperation.java | 2 +- src/main/resources/.gitkeep | 0 src/main/resources/application.yml | 21 ---- 7 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/dev_deploy.yml create mode 100644 .github/workflows/dev_test.yml create mode 100644 src/main/resources/.gitkeep delete mode 100644 src/main/resources/application.yml 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 index c2065bc..aa1333c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ out/ ### VS Code ### .vscode/ + +### Custom ### +application*.yml +application*.yaml diff --git a/build.gradle b/build.gradle index 395eae9..50f85ea 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,10 @@ dependencies { 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.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' // swagger + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java b/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java index 4928fcf..f480296 100644 --- a/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java +++ b/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java @@ -25,7 +25,7 @@ public class DocumentOperation extends BaseTimeEntity { @JoinColumn(name = "document_id") private Document document; - private Integer index; // 수정된 내용의 인덱스 + private Integer position; // 수정된 내용의 인덱스 private String content; // 삽입된 텍스트 } diff --git a/src/main/resources/.gitkeep b/src/main/resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 714cd7a..0000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,21 +0,0 @@ -spring: - datasource: - url: jdbc:h2:mem:testdb # ??? ? H2 ?????? ?? - driverClassName: org.h2.Driver - username: sa - password: - hikari: - maximum-pool-size: 10 # ??? ? ?? ?? (??? ??) - - jpa: - hibernate: - ddl-auto: update # ???? ?? ??? ?? ??/???? - show-sql: true # ???? SQL? ??? ?? - properties: - hibernate: - format_sql: true # SQL ???? ?? ?? ?? - - h2: - console: - enabled: true # H2 ?? ??? - path: /h2-console # H2 ?? ?? From ad5b2d73ea98ac51e45291b939f7620703f48605 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:09:31 +0900 Subject: [PATCH 04/34] =?UTF-8?q?[FEAT]=20=EB=AC=B8=EC=84=9C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A4=91=EA=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add: cicd 스크립트 추가 * add: 테스트 스크립트 추가 * del: application.yml 삭제 * add: resources/ 더미 파일 추가 * add: 디펜던시 추가 * update: DocumentOperation index 필드명 수정 * add: config * fix: WebsocketConfig 누락 어노테이션 추가 * update: 엔티티에 @Getter 추가 * update: 웹소켓 관련 엔티티 변경 * update: AckEvent 엔티티 추가 * update: AckEvent에서 개명 * add: config 추가 * feat: 웹소켓 편집 이벤트 중계 구현 * feat: 웹소켓 편집 이벤트 중계까지 완성 --- build.gradle | 2 + .../backend/config/MessageQueueConfig.java | 27 +++++++ .../gdg/backend/config/WebsocketConfig.java | 25 +++++++ .../domain/attendence/entity/Attendance.java | 2 + .../backend/domain/build/entity/Build.java | 2 + .../domain/channel/entity/Channel.java | 4 ++ .../domain/classroom/entity/Classroom.java | 2 + .../backend/domain/course/entity/Course.java | 2 + .../domain/document/entity/Document.java | 4 ++ .../repository/DocumentRepository.java | 13 ++++ .../gdg/backend/domain/enums/Operation.java | 3 +- .../controller/WebsocketEventController.java | 33 +++++++++ .../event/dto/DocumentOperationAck.java | 15 ++++ .../dto/DocumentOperationRequestDto.java | 27 +++++++ .../dto/DocumentOperationResponseDto.java | 28 ++++++++ .../entity/DocumentOperation.java | 9 ++- .../service/OperationQueueProcessor.java | 72 +++++++++++++++++++ .../domain/invitation/entity/Invitation.java | 2 + .../backend/domain/member/entity/Student.java | 2 + .../backend/domain/notice/entity/Notice.java | 2 + 20 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/gdg/backend/config/MessageQueueConfig.java create mode 100644 src/main/java/com/gdg/backend/config/WebsocketConfig.java create mode 100644 src/main/java/com/gdg/backend/domain/document/repository/DocumentRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java create mode 100644 src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationAck.java create mode 100644 src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationRequestDto.java create mode 100644 src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationResponseDto.java rename src/main/java/com/gdg/backend/domain/{documentoperation => event}/entity/DocumentOperation.java (78%) create mode 100644 src/main/java/com/gdg/backend/domain/event/service/OperationQueueProcessor.java diff --git a/build.gradle b/build.gradle index 50f85ea..ba7ea79 100644 --- a/build.gradle +++ b/build.gradle @@ -31,7 +31,9 @@ dependencies { 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.2.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' 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..dd310ca --- /dev/null +++ b/src/main/java/com/gdg/backend/config/MessageQueueConfig.java @@ -0,0 +1,27 @@ +package com.gdg.backend.config; + +import com.gdg.backend.domain.event.dto.DocumentOperationRequestDto; +import com.gdg.backend.domain.event.dto.DocumentOperationResponseDto; +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/WebsocketConfig.java b/src/main/java/com/gdg/backend/config/WebsocketConfig.java new file mode 100644 index 0000000..a01ef7f --- /dev/null +++ b/src/main/java/com/gdg/backend/config/WebsocketConfig.java @@ -0,0 +1,25 @@ +package com.gdg.backend.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +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/attendence/entity/Attendance.java b/src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java index caaddec..070d188 100644 --- a/src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java +++ b/src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java @@ -4,8 +4,10 @@ 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 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 index 1402b00..88053ad 100644 --- a/src/main/java/com/gdg/backend/domain/build/entity/Build.java +++ b/src/main/java/com/gdg/backend/domain/build/entity/Build.java @@ -4,8 +4,10 @@ import com.gdg.backend.domain.document.entity.Document; import com.gdg.backend.domain.member.entity.Member; import jakarta.persistence.*; +import lombok.Getter; @Entity +@Getter public class Build extends BaseTimeEntity { @Id 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 index 82c18bb..026bc25 100644 --- a/src/main/java/com/gdg/backend/domain/channel/entity/Channel.java +++ b/src/main/java/com/gdg/backend/domain/channel/entity/Channel.java @@ -1,4 +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/entity/Classroom.java b/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java index 44a4170..f172930 100644 --- a/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java +++ b/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java @@ -4,10 +4,12 @@ import com.gdg.backend.domain.invitation.entity.Invitation; import com.gdg.backend.domain.notice.entity.Notice; import jakarta.persistence.*; +import lombok.Getter; import java.util.List; @Entity +@Getter public class Classroom { @Id 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 index 5323aea..874ecb0 100644 --- a/src/main/java/com/gdg/backend/domain/course/entity/Course.java +++ b/src/main/java/com/gdg/backend/domain/course/entity/Course.java @@ -4,10 +4,12 @@ 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 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 index daababe..fd018b5 100644 --- a/src/main/java/com/gdg/backend/domain/document/entity/Document.java +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -4,8 +4,10 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.Getter; @Entity +@Getter public class Document { @Id @@ -13,4 +15,6 @@ public class Document { private Long id; private String content; + + private Long version; } 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..7c27ba2 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/document/repository/DocumentRepository.java @@ -0,0 +1,13 @@ +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; + +@Repository +public interface DocumentRepository extends JpaRepository { + +} diff --git a/src/main/java/com/gdg/backend/domain/enums/Operation.java b/src/main/java/com/gdg/backend/domain/enums/Operation.java index 1d7fa3c..6ecf269 100644 --- a/src/main/java/com/gdg/backend/domain/enums/Operation.java +++ b/src/main/java/com/gdg/backend/domain/enums/Operation.java @@ -3,5 +3,6 @@ public enum Operation { INSERT, DELETE, - UPDATE + UPDATE, + CURSOR } \ No newline at end of file diff --git a/src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java b/src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java new file mode 100644 index 0000000..2a93a2e --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java @@ -0,0 +1,33 @@ +package com.gdg.backend.domain.event.controller; + +import com.gdg.backend.domain.event.dto.DocumentOperationRequestDto; +import com.gdg.backend.domain.event.dto.DocumentOperationResponseDto; +import jakarta.validation.Valid; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +import java.util.concurrent.BlockingQueue; + +@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 receiveEditOperation(@Valid DocumentOperationRequestDto operation) throws InterruptedException { + operationQueue.put(operation); + template.convertAndSend("/sub/ack/" + operation.getDocumentId(), "ACK"); + } + + +} diff --git a/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationAck.java b/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationAck.java new file mode 100644 index 0000000..e092dea --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationAck.java @@ -0,0 +1,15 @@ +package com.gdg.backend.domain.event.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** 클라이언트가 서버에게 보내는 확인 메시지 */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DocumentOperationAck { + private Long documentId; + private Long version; +} diff --git a/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationRequestDto.java b/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationRequestDto.java new file mode 100644 index 0000000..ad27fb5 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationRequestDto.java @@ -0,0 +1,27 @@ +package com.gdg.backend.domain.event.dto; + +import com.gdg.backend.domain.enums.Operation; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DocumentOperationRequestDto { + @NotNull(message = "operation은 null일 수 없습니다.") + Operation operation; + + @NotNull(message = "documentId는 null일 수 없습니다.") + Long documentId; + + String content; + + @NotNull(message = "position은 null일 수 없습니다.") + Long position; + + Long baseVersion; +} diff --git a/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationResponseDto.java b/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationResponseDto.java new file mode 100644 index 0000000..3e209d4 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationResponseDto.java @@ -0,0 +1,28 @@ +package com.gdg.backend.domain.event.dto; + + +import com.gdg.backend.domain.enums.Operation; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DocumentOperationResponseDto { + Operation operation; + Long documentId; + String content; + Long position; + Long version; + + public static DocumentOperationResponseDto of(DocumentOperationRequestDto request) { + DocumentOperationResponseDto response = new DocumentOperationResponseDto(); + response.setOperation(request.getOperation()); + response.setDocumentId(request.getDocumentId()); + response.setContent(request.getContent()); + response.setPosition(request.getPosition()); + response.setVersion(request.getBaseVersion()); + return response; + } +} diff --git a/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java b/src/main/java/com/gdg/backend/domain/event/entity/DocumentOperation.java similarity index 78% rename from src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java rename to src/main/java/com/gdg/backend/domain/event/entity/DocumentOperation.java index f480296..365993c 100644 --- a/src/main/java/com/gdg/backend/domain/documentoperation/entity/DocumentOperation.java +++ b/src/main/java/com/gdg/backend/domain/event/entity/DocumentOperation.java @@ -1,14 +1,15 @@ -package com.gdg.backend.domain.documentoperation.entity; +package com.gdg.backend.domain.event.entity; import com.gdg.backend.common.entity.BaseTimeEntity; import com.gdg.backend.domain.enums.Operation; import com.gdg.backend.domain.document.entity.Document; import com.gdg.backend.domain.member.entity.Member; import jakarta.persistence.*; - +import lombok.Getter; @Entity +@Getter public class DocumentOperation extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -25,7 +26,9 @@ public class DocumentOperation extends BaseTimeEntity { @JoinColumn(name = "document_id") private Document document; - private Integer position; // 수정된 내용의 인덱스 + private Long position; // 수정된 내용의 인덱스 private String content; // 삽입된 텍스트 + + private Long version; // 적용 순서 } diff --git a/src/main/java/com/gdg/backend/domain/event/service/OperationQueueProcessor.java b/src/main/java/com/gdg/backend/domain/event/service/OperationQueueProcessor.java new file mode 100644 index 0000000..b003a0e --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/event/service/OperationQueueProcessor.java @@ -0,0 +1,72 @@ +package com.gdg.backend.domain.event.service; + +import com.gdg.backend.domain.document.entity.Document; +import com.gdg.backend.domain.document.repository.DocumentRepository; +import com.gdg.backend.domain.event.dto.DocumentOperationRequestDto; +import com.gdg.backend.domain.event.dto.DocumentOperationResponseDto; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + + +/** DocumentOperation 큐에서 주기적으로 이벤트를 가져와 처리하는 클래스 */ +@Component +@RequiredArgsConstructor +public class OperationQueueProcessor { + + private final DocumentRepository documentRepository; + private final BlockingQueue operationQueue; + private final SimpMessagingTemplate template; + private final ConcurrentHashMap documentVersions = new ConcurrentHashMap<>(); + private final ConcurrentHashMap documents = new ConcurrentHashMap<>(); + + @PostConstruct + public void startProcessing() { + // 별도 스레드에서 큐를 polling 하여 처리 + new Thread(() -> { + while(true) { + try { + DocumentOperationRequestDto operation = operationQueue.take(); + processOperation(operation); + } catch (InterruptedException e) { + System.out.println("QUEUE PROCESSOR THREAD INTERRUPTED!"); + System.out.println(e.getMessage()); + break; + } + } + }).start(); + } + + /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ + @PostConstruct + public void fillDocumentVersionPool () { + List documents = documentRepository.findAll(); + documents.stream().forEach(document -> { + documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); + }); + } + + private void processOperation(DocumentOperationRequestDto operation) { + // TODO OT 알고리즘 적용 + // TODO documentID 없는 경우 예외 처리 + + // 버전 부여 + DocumentOperationResponseDto response = DocumentOperationResponseDto.of(operation); + response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); + + // todo 서버 문서 상태에도 변경사항 가함 + + // todo Operation DB에 저장 && Document version 업데이트 + // - 동기 처리 vs 비동기 처리 + + // 클라이언트에 브로드캐스트 + Long docId = operation.getDocumentId(); + template.convertAndSend("/sub/edit/" + docId, 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 index 9c905d5..b161e05 100644 --- a/src/main/java/com/gdg/backend/domain/invitation/entity/Invitation.java +++ b/src/main/java/com/gdg/backend/domain/invitation/entity/Invitation.java @@ -4,8 +4,10 @@ import com.gdg.backend.domain.classroom.entity.Classroom; import com.gdg.backend.domain.member.entity.Member; import jakarta.persistence.*; +import lombok.Getter; @Entity +@Getter public class Invitation extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 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 index 41c8d8c..e1598c6 100644 --- a/src/main/java/com/gdg/backend/domain/member/entity/Student.java +++ b/src/main/java/com/gdg/backend/domain/member/entity/Student.java @@ -1,8 +1,10 @@ package com.gdg.backend.domain.member.entity; import jakarta.persistence.Entity; +import lombok.Getter; @Entity +@Getter public class Student extends Member { } 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 index 6e45696..0559dcd 100644 --- a/src/main/java/com/gdg/backend/domain/notice/entity/Notice.java +++ b/src/main/java/com/gdg/backend/domain/notice/entity/Notice.java @@ -2,8 +2,10 @@ import com.gdg.backend.domain.classroom.entity.Classroom; import jakarta.persistence.*; +import lombok.Getter; @Entity +@Getter public class Notice { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) From ec8279fe1ca5cfba96aa427da8d2d795c49162e7 Mon Sep 17 00:00:00 2001 From: YamYamee <82762402+YamYamee@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:55:10 +0900 Subject: [PATCH 05/34] =?UTF-8?q?[FEAT]=20=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85=20=EB=B0=8F=20CORS=20=EC=84=A4=EC=A0=95=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add: swagger 및 error handling * add: CORS 설정 추가 * add: ExceptionController 추가 --------- Co-authored-by: JaehwanH --- build.gradle | 1 - .../common/exception/ExceptionAdvice.java | 85 +++++++++++++++++++ .../common/exception/GeneralException.java | 21 +++++ .../controller/ExceptionController.java | 37 ++++++++ .../exception/handler/GeneralHandler.java | 10 +++ .../backend/common/response/ApiResponse.java | 40 +++++++++ .../gdg/backend/common/response/BaseCode.java | 8 ++ .../common/response/BaseErrorCode.java | 8 ++ .../common/response/ErrorReasonDTO.java | 18 ++++ .../backend/common/response/ReasonDTO.java | 18 ++++ .../common/response/status/ErrorCode.java | 57 +++++++++++++ .../common/response/status/SuccessCode.java | 45 ++++++++++ .../com/gdg/backend/config/SwaggerConfig.java | 26 ++++++ .../com/gdg/backend/config/WebConfig.java | 19 +++++ .../controller/WebsocketEventController.java | 1 + 15 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gdg/backend/common/exception/ExceptionAdvice.java create mode 100644 src/main/java/com/gdg/backend/common/exception/GeneralException.java create mode 100644 src/main/java/com/gdg/backend/common/exception/controller/ExceptionController.java create mode 100644 src/main/java/com/gdg/backend/common/exception/handler/GeneralHandler.java create mode 100644 src/main/java/com/gdg/backend/common/response/ApiResponse.java create mode 100644 src/main/java/com/gdg/backend/common/response/BaseCode.java create mode 100644 src/main/java/com/gdg/backend/common/response/BaseErrorCode.java create mode 100644 src/main/java/com/gdg/backend/common/response/ErrorReasonDTO.java create mode 100644 src/main/java/com/gdg/backend/common/response/ReasonDTO.java create mode 100644 src/main/java/com/gdg/backend/common/response/status/ErrorCode.java create mode 100644 src/main/java/com/gdg/backend/common/response/status/SuccessCode.java create mode 100644 src/main/java/com/gdg/backend/config/SwaggerConfig.java create mode 100644 src/main/java/com/gdg/backend/config/WebConfig.java diff --git a/build.gradle b/build.gradle index ba7ea79..ac001e5 100644 --- a/build.gradle +++ b/build.gradle @@ -34,7 +34,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.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' } 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..c1a075a --- /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); + 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..1118bdb --- /dev/null +++ b/src/main/java/com/gdg/backend/common/exception/controller/ExceptionController.java @@ -0,0 +1,37 @@ +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()); + + int status = (int) errorAttributes.getOrDefault("status", 500); + String message = (String) errorAttributes.getOrDefault("message", "Unexpected error"); + + System.out.println("CustomErrorController: received status : " + status); + System.out.println("CustomErrorController: error message - " + message); + + return ApiResponse.onFailure(ErrorCode._INTERNAL_SERVER_ERROR.getCode(), "알 수 없는 에러입니다.", "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/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..ea77308 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java @@ -0,0 +1,57 @@ +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", "로그인 되어 있지 않습니다."), + + ; + + + 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/config/SwaggerConfig.java b/src/main/java/com/gdg/backend/config/SwaggerConfig.java new file mode 100644 index 0000000..d050476 --- /dev/null +++ b/src/main/java/com/gdg/backend/config/SwaggerConfig.java @@ -0,0 +1,26 @@ +package com.gdg.backend.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +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 컨텍스트에서 OpenAPI 객체를 빈으로 등록 + public OpenAPI openApiConfig() { + + // Swagger UI에서 API 문서의 정보를 설정 + Info info = new Info() + .title("My API") // API의 제목 설정 + .description("This is the API documentation for My API") // API의 설명 설정 + .version("1.0.0"); // API의 버전 설정 + + // OpenAPI 객체 생성 및 구성 + return new OpenAPI() + .addServersItem(new Server().url("/")) // 기본 서버 URL 설정 (현재 루트 경로) + .info(info); // API 정보 추가 + } +} 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..07e6818 --- /dev/null +++ b/src/main/java/com/gdg/backend/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.gdg.backend.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @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/domain/event/controller/WebsocketEventController.java b/src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java index 2a93a2e..2c07dbc 100644 --- a/src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java +++ b/src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java @@ -6,6 +6,7 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.CrossOrigin; import java.util.concurrent.BlockingQueue; From f329c357a71e5916555ba414b7c312d781facaac Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Sat, 1 Mar 2025 03:00:19 +0900 Subject: [PATCH 06/34] =?UTF-8?q?[FEAT]=20=EC=B6=A9=EB=8F=8C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add: cicd 스크립트 추가 * add: 테스트 스크립트 추가 * del: application.yml 삭제 * add: resources/ 더미 파일 추가 * add: 디펜던시 추가 * update: DocumentOperation index 필드명 수정 * add: config * fix: WebsocketConfig 누락 어노테이션 추가 * update: 엔티티에 @Getter 추가 * update: 웹소켓 관련 엔티티 변경 * update: AckEvent 엔티티 추가 * update: AckEvent에서 개명 * add: config 추가 * feat: 웹소켓 편집 이벤트 중계 구현 * feat: 웹소켓 편집 이벤트 중계까지 완성 * rename: DocumentOperation -> Operation 개명 - 이름이 너무 길어 * rename: Operation enum -> OperationType * update: Operation 관련 클래스에 userId 필드 추가 * add: OperationRepository * fix: Operation 필드 변경 - deleteLength 추가 - content -> insertContent로 변경 * feat: OT 구현 - 완전 느릴듯 * update: 로그 출력 추가 * comment: 주석 업데이트 --- .../backend/config/MessageQueueConfig.java | 5 +- .../domain/document/entity/Document.java | 9 +- .../{Operation.java => OperationType.java} | 2 +- .../dto/DocumentOperationRequestDto.java | 27 ---- .../dto/DocumentOperationResponseDto.java | 28 ---- .../service/OperationQueueProcessor.java | 72 --------- .../controller/WebsocketEventController.java | 11 +- .../dto/OperationAck.java} | 4 +- .../operation/dto/OperationRequestDto.java | 41 ++++++ .../operation/dto/OperationResponseDto.java | 32 ++++ .../entity/Operation.java} | 23 ++- .../repository/OperationRepository.java | 14 ++ .../service/OperationQueueProcessor.java | 138 ++++++++++++++++++ 13 files changed, 260 insertions(+), 146 deletions(-) rename src/main/java/com/gdg/backend/domain/enums/{Operation.java => OperationType.java} (75%) delete mode 100644 src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationRequestDto.java delete mode 100644 src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationResponseDto.java delete mode 100644 src/main/java/com/gdg/backend/domain/event/service/OperationQueueProcessor.java rename src/main/java/com/gdg/backend/domain/{event => operation}/controller/WebsocketEventController.java (65%) rename src/main/java/com/gdg/backend/domain/{event/dto/DocumentOperationAck.java => operation/dto/OperationAck.java} (76%) create mode 100644 src/main/java/com/gdg/backend/domain/operation/dto/OperationRequestDto.java create mode 100644 src/main/java/com/gdg/backend/domain/operation/dto/OperationResponseDto.java rename src/main/java/com/gdg/backend/domain/{event/entity/DocumentOperation.java => operation/entity/Operation.java} (54%) create mode 100644 src/main/java/com/gdg/backend/domain/operation/repository/OperationRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java diff --git a/src/main/java/com/gdg/backend/config/MessageQueueConfig.java b/src/main/java/com/gdg/backend/config/MessageQueueConfig.java index dd310ca..16ffd3f 100644 --- a/src/main/java/com/gdg/backend/config/MessageQueueConfig.java +++ b/src/main/java/com/gdg/backend/config/MessageQueueConfig.java @@ -1,7 +1,6 @@ package com.gdg.backend.config; -import com.gdg.backend.domain.event.dto.DocumentOperationRequestDto; -import com.gdg.backend.domain.event.dto.DocumentOperationResponseDto; +import com.gdg.backend.domain.operation.dto.OperationRequestDto; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,7 +18,7 @@ public class MessageQueueConfig { * - 용량은 일단 1000으로 세팅 (꽉 찬 후의 메시지는 공간 생길 때까지 wait)
* */ @Bean - public BlockingQueue eventQueue() { + public BlockingQueue eventQueue() { // TODO Document마다 메시지큐 따로 마련하기 (Map 형식으로) // TODO 아니면 아예 Redis나 RabbitMQ 등등 외부 메시지 큐로 옮기기 (옮길 땐 선택이유도 같이 생각해두기!) return new LinkedBlockingQueue<>(1000); 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 index fd018b5..505d48b 100644 --- a/src/main/java/com/gdg/backend/domain/document/entity/Document.java +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -1,14 +1,21 @@ package com.gdg.backend.domain.document.entity; +import com.gdg.backend.common.entity.BaseTimeEntity; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter -public class Document { +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Document extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/gdg/backend/domain/enums/Operation.java b/src/main/java/com/gdg/backend/domain/enums/OperationType.java similarity index 75% rename from src/main/java/com/gdg/backend/domain/enums/Operation.java rename to src/main/java/com/gdg/backend/domain/enums/OperationType.java index 6ecf269..3e55a47 100644 --- a/src/main/java/com/gdg/backend/domain/enums/Operation.java +++ b/src/main/java/com/gdg/backend/domain/enums/OperationType.java @@ -1,6 +1,6 @@ package com.gdg.backend.domain.enums; -public enum Operation { +public enum OperationType { INSERT, DELETE, UPDATE, diff --git a/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationRequestDto.java b/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationRequestDto.java deleted file mode 100644 index ad27fb5..0000000 --- a/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationRequestDto.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.gdg.backend.domain.event.dto; - -import com.gdg.backend.domain.enums.Operation; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class DocumentOperationRequestDto { - @NotNull(message = "operation은 null일 수 없습니다.") - Operation operation; - - @NotNull(message = "documentId는 null일 수 없습니다.") - Long documentId; - - String content; - - @NotNull(message = "position은 null일 수 없습니다.") - Long position; - - Long baseVersion; -} diff --git a/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationResponseDto.java b/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationResponseDto.java deleted file mode 100644 index 3e209d4..0000000 --- a/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationResponseDto.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.gdg.backend.domain.event.dto; - - -import com.gdg.backend.domain.enums.Operation; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class DocumentOperationResponseDto { - Operation operation; - Long documentId; - String content; - Long position; - Long version; - - public static DocumentOperationResponseDto of(DocumentOperationRequestDto request) { - DocumentOperationResponseDto response = new DocumentOperationResponseDto(); - response.setOperation(request.getOperation()); - response.setDocumentId(request.getDocumentId()); - response.setContent(request.getContent()); - response.setPosition(request.getPosition()); - response.setVersion(request.getBaseVersion()); - return response; - } -} diff --git a/src/main/java/com/gdg/backend/domain/event/service/OperationQueueProcessor.java b/src/main/java/com/gdg/backend/domain/event/service/OperationQueueProcessor.java deleted file mode 100644 index b003a0e..0000000 --- a/src/main/java/com/gdg/backend/domain/event/service/OperationQueueProcessor.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.gdg.backend.domain.event.service; - -import com.gdg.backend.domain.document.entity.Document; -import com.gdg.backend.domain.document.repository.DocumentRepository; -import com.gdg.backend.domain.event.dto.DocumentOperationRequestDto; -import com.gdg.backend.domain.event.dto.DocumentOperationResponseDto; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; - - -/** DocumentOperation 큐에서 주기적으로 이벤트를 가져와 처리하는 클래스 */ -@Component -@RequiredArgsConstructor -public class OperationQueueProcessor { - - private final DocumentRepository documentRepository; - private final BlockingQueue operationQueue; - private final SimpMessagingTemplate template; - private final ConcurrentHashMap documentVersions = new ConcurrentHashMap<>(); - private final ConcurrentHashMap documents = new ConcurrentHashMap<>(); - - @PostConstruct - public void startProcessing() { - // 별도 스레드에서 큐를 polling 하여 처리 - new Thread(() -> { - while(true) { - try { - DocumentOperationRequestDto operation = operationQueue.take(); - processOperation(operation); - } catch (InterruptedException e) { - System.out.println("QUEUE PROCESSOR THREAD INTERRUPTED!"); - System.out.println(e.getMessage()); - break; - } - } - }).start(); - } - - /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ - @PostConstruct - public void fillDocumentVersionPool () { - List documents = documentRepository.findAll(); - documents.stream().forEach(document -> { - documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); - }); - } - - private void processOperation(DocumentOperationRequestDto operation) { - // TODO OT 알고리즘 적용 - // TODO documentID 없는 경우 예외 처리 - - // 버전 부여 - DocumentOperationResponseDto response = DocumentOperationResponseDto.of(operation); - response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); - - // todo 서버 문서 상태에도 변경사항 가함 - - // todo Operation DB에 저장 && Document version 업데이트 - // - 동기 처리 vs 비동기 처리 - - // 클라이언트에 브로드캐스트 - Long docId = operation.getDocumentId(); - template.convertAndSend("/sub/edit/" + docId, response); - } -} diff --git a/src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java similarity index 65% rename from src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java rename to src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java index 2c07dbc..15d1f4c 100644 --- a/src/main/java/com/gdg/backend/domain/event/controller/WebsocketEventController.java +++ b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java @@ -1,7 +1,6 @@ -package com.gdg.backend.domain.event.controller; +package com.gdg.backend.domain.operation.controller; -import com.gdg.backend.domain.event.dto.DocumentOperationRequestDto; -import com.gdg.backend.domain.event.dto.DocumentOperationResponseDto; +import com.gdg.backend.domain.operation.dto.OperationRequestDto; import jakarta.validation.Valid; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -12,11 +11,11 @@ @Controller public class WebsocketEventController { - private final BlockingQueue operationQueue; + private final BlockingQueue operationQueue; private final SimpMessagingTemplate template; public WebsocketEventController( - BlockingQueue operationQueue, + BlockingQueue operationQueue, SimpMessagingTemplate template ){ this.operationQueue = operationQueue; @@ -25,7 +24,7 @@ public WebsocketEventController( /** 클라이언트의 문서 편집 요청을 메시지 큐에 push */ @MessageMapping("/edit") - public void receiveEditOperation(@Valid DocumentOperationRequestDto operation) throws InterruptedException { + public void receiveEditOperation(@Valid OperationRequestDto operation) throws InterruptedException { operationQueue.put(operation); template.convertAndSend("/sub/ack/" + operation.getDocumentId(), "ACK"); } diff --git a/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationAck.java b/src/main/java/com/gdg/backend/domain/operation/dto/OperationAck.java similarity index 76% rename from src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationAck.java rename to src/main/java/com/gdg/backend/domain/operation/dto/OperationAck.java index e092dea..27d5d1b 100644 --- a/src/main/java/com/gdg/backend/domain/event/dto/DocumentOperationAck.java +++ b/src/main/java/com/gdg/backend/domain/operation/dto/OperationAck.java @@ -1,4 +1,4 @@ -package com.gdg.backend.domain.event.dto; +package com.gdg.backend.domain.operation.dto; import lombok.AllArgsConstructor; import lombok.Data; @@ -9,7 +9,7 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class DocumentOperationAck { +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..df9d2da --- /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..7f81562 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/dto/OperationResponseDto.java @@ -0,0 +1,32 @@ +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(response.getDeleteLength()); + response.setPosition(request.getPosition()); + response.setVersion(request.getBaseVersion()); + response.setUserId(request.getUserId()); + return response; + } +} diff --git a/src/main/java/com/gdg/backend/domain/event/entity/DocumentOperation.java b/src/main/java/com/gdg/backend/domain/operation/entity/Operation.java similarity index 54% rename from src/main/java/com/gdg/backend/domain/event/entity/DocumentOperation.java rename to src/main/java/com/gdg/backend/domain/operation/entity/Operation.java index 365993c..ac884a0 100644 --- a/src/main/java/com/gdg/backend/domain/event/entity/DocumentOperation.java +++ b/src/main/java/com/gdg/backend/domain/operation/entity/Operation.java @@ -1,22 +1,30 @@ -package com.gdg.backend.domain.event.entity; +package com.gdg.backend.domain.operation.entity; import com.gdg.backend.common.entity.BaseTimeEntity; -import com.gdg.backend.domain.enums.Operation; 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 -public class DocumentOperation extends BaseTimeEntity { +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Operation extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Enumerated(EnumType.STRING) - private Operation operation; + private OperationType operation; @ManyToOne @JoinColumn(name = "member_id") @@ -28,7 +36,10 @@ public class DocumentOperation extends BaseTimeEntity { private Long position; // 수정된 내용의 인덱스 - private String content; // 삽입된 텍스트 - + 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..729093f --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/repository/OperationRepository.java @@ -0,0 +1,14 @@ +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.stereotype.Repository; + +import java.util.List; + +@Repository +public interface OperationRepository extends JpaRepository { + + List findByDocumentIdAndVersionGreaterThan(Long documentId, Long version); +} + 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..340937b --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -0,0 +1,138 @@ +package com.gdg.backend.domain.operation.service; + +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.operation.dto.OperationRequestDto; +import com.gdg.backend.domain.operation.dto.OperationResponseDto; +import com.gdg.backend.domain.operation.entity.Operation; +import com.gdg.backend.domain.operation.repository.OperationRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + + +/** OperationType 큐에서 주기적으로 이벤트를 가져와 처리하는 클래스 */ +@Component +@RequiredArgsConstructor +public class OperationQueueProcessor { + + private final DocumentRepository documentRepository; + private final OperationRepository operationRepository; + private final BlockingQueue operationQueue; + private final SimpMessagingTemplate template; + private final ConcurrentHashMap documentVersions = new ConcurrentHashMap<>(); + + // 문서 상태 추적 (Stringbuilder vs Document?) + private final ConcurrentHashMap documentState = new ConcurrentHashMap<>(); + + @PostConstruct + public void startProcessing() { + // 별도 스레드에서 큐를 polling 하여 처리 + new Thread(() -> { + while(true) { + try { + OperationRequestDto operation = operationQueue.take(); + processOperation(operation); + } catch (InterruptedException e) { + System.out.println("QUEUE PROCESSOR THREAD INTERRUPTED!"); + System.out.println(e.getMessage()); + break; + } + } + }).start(); + } + + /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ + @PostConstruct + public void fillDocumentVersionPool () { + List documents = documentRepository.findAll(); + Document testDoc; + Optional optionalTestDoc = documents.stream().filter((document) -> document.getId() == 1).findFirst(); + testDoc = optionalTestDoc.orElseGet(() -> { + Document doc = Document.builder() + .id(1L) + .version(0L) + .content("") + .build(); + documentRepository.save(doc); + return doc; + }); + + documents.stream().forEach(document -> { + documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); + }); + } + + private void processOperation(OperationRequestDto operation) { + // todo documentID 없는 경우 예외 처리 + + // 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)번으로 갈아타기 + + Long docId = operation.getDocumentId(); + Long baseVersion = operation.getBaseVersion(); + Long opPosition = operation.getPosition(); + List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); + for(Operation concurrentOp: concurrentOperations) { + if(concurrentOp.getOperation().equals(OperationType.INSERT) + && concurrentOp.getPosition() < opPosition) { + // 현재 operation보다 앞에 삽입한 경우 + opPosition += concurrentOp.getInsertContent().length(); + } + else if(concurrentOp.getOperation().equals(OperationType.DELETE) + && concurrentOp.getPosition() < opPosition) { + // 현재 operation보다 앞을 삭제한 경우 + opPosition -= concurrentOp.getDeleteLength(); + } + } + + // 버전 부여 + OperationResponseDto response = OperationResponseDto.of(operation); + response.setPosition(opPosition); + response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); + + // todo 서버 문서 상태에도 변경사항 가함 + + + // Operation DB에 저장 && Document version 업데이트 + // - 동기 처리 vs 비동기 처리 + // - todo 메모리에 Operation랑 Document 캐싱하기 + // - Operation은 큐 만들어서 캐싱하기 (클라이언트 ACK에 맞춰 갱신) + // - Document는 Map 형식 or Map 형식으로 저장? + operationRepository.save(Operation.builder() + .operation(response.getOperation()) + .document(documentRepository.findById(docId).orElseThrow()) // todo + .position(response.getPosition()) + .insertContent(response.getInsertContent()) + .deleteLength(response.getDeleteLength()) + .version(response.getVersion()) + .member(null) // todo + .build() + ); + + // 로그 출력 + System.out.println("Received: " + operation); + System.out.println(" 수정된 위치: " + opPosition); + System.out.println(" 수정된 버전: " + response.getVersion()); + + // 클라이언트에 브로드캐스트 + template.convertAndSend("/sub/edit/" + docId, response); + } +} From d7a6930b4bb5ef739793087d7c33aa2de9cc4f36 Mon Sep 17 00:00:00 2001 From: YamYamee Date: Thu, 6 Mar 2025 16:06:16 +0900 Subject: [PATCH 07/34] =?UTF-8?q?add:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 +- .../backend/common/annotation/AuthUser.java | 14 + .../common/exception/ExceptionAdvice.java | 2 +- .../controller/ExceptionController.java | 7 +- .../jwt/CustomAuthenticationEntryPoint.java | 29 ++ .../common/jwt/CustomPasswordEncoder.java | 28 ++ .../common/jwt/JwtAuthenticationFilter.java | 69 +++++ .../backend/common/jwt/JwtTokenProvider.java | 71 +++++ .../resolver/AuthUserArgumentResolver.java | 43 +++ .../common/response/status/ErrorCode.java | 2 +- .../gdg/backend/config/SecurityConfig.java | 38 +++ .../com/gdg/backend/config/SwaggerConfig.java | 31 +- .../domain/document/entity/Document.java | 6 +- .../gdg/backend/domain/enums/MemberType.java | 6 + .../member/controller/MemberController.java | 39 +++ .../domain/member/dto/SignInRequestDto.java | 23 ++ .../domain/member/dto/SignInResponseDto.java | 14 + .../domain/member/dto/SignUpRequestDto.java | 32 ++ .../domain/member/dto/SignUpResponseDto.java | 14 + .../backend/domain/member/entity/Member.java | 11 +- .../backend/domain/member/entity/Student.java | 5 + .../backend/domain/member/entity/Teacher.java | 15 + .../domain/member/entity/UserPrincipal.java | 56 ++++ .../member/repository/MemberRepository.java | 11 + .../member/repository/StudentRepository.java | 14 + .../member/repository/TeacherRepository.java | 12 + .../domain/member/service/MemberService.java | 11 + .../member/service/MemberServiceImpl.java | 104 +++++++ .../service/OperationQueueProcessor.java | 276 +++++++++--------- src/main/resources/.gitkeep | 0 30 files changed, 835 insertions(+), 156 deletions(-) create mode 100644 src/main/java/com/gdg/backend/common/annotation/AuthUser.java create mode 100644 src/main/java/com/gdg/backend/common/jwt/CustomAuthenticationEntryPoint.java create mode 100644 src/main/java/com/gdg/backend/common/jwt/CustomPasswordEncoder.java create mode 100644 src/main/java/com/gdg/backend/common/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/gdg/backend/common/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/gdg/backend/common/resolver/AuthUserArgumentResolver.java create mode 100644 src/main/java/com/gdg/backend/config/SecurityConfig.java create mode 100644 src/main/java/com/gdg/backend/domain/enums/MemberType.java create mode 100644 src/main/java/com/gdg/backend/domain/member/controller/MemberController.java create mode 100644 src/main/java/com/gdg/backend/domain/member/dto/SignInRequestDto.java create mode 100644 src/main/java/com/gdg/backend/domain/member/dto/SignInResponseDto.java create mode 100644 src/main/java/com/gdg/backend/domain/member/dto/SignUpRequestDto.java create mode 100644 src/main/java/com/gdg/backend/domain/member/dto/SignUpResponseDto.java create mode 100644 src/main/java/com/gdg/backend/domain/member/entity/Teacher.java create mode 100644 src/main/java/com/gdg/backend/domain/member/entity/UserPrincipal.java create mode 100644 src/main/java/com/gdg/backend/domain/member/repository/MemberRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/member/repository/StudentRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/member/repository/TeacherRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/member/service/MemberService.java create mode 100644 src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java delete mode 100644 src/main/resources/.gitkeep diff --git a/build.gradle b/build.gradle index ac001e5..9faa480 100644 --- a/build.gradle +++ b/build.gradle @@ -32,10 +32,16 @@ dependencies { 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.2.0' // swagger + 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') { 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/exception/ExceptionAdvice.java b/src/main/java/com/gdg/backend/common/exception/ExceptionAdvice.java index c1a075a..b70f66c 100644 --- a/src/main/java/com/gdg/backend/common/exception/ExceptionAdvice.java +++ b/src/main/java/com/gdg/backend/common/exception/ExceptionAdvice.java @@ -56,7 +56,7 @@ public ResponseEntity handleGenericException(Exception e, WebRequest req @ExceptionHandler(GeneralException.class) public ResponseEntity handleGeneralException(GeneralException generalException, HttpServletRequest request) { - log.error("General exception occurred: ", generalException); + log.error("General exception occurred: {}", generalException.getCode()); ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus(); return handleExceptionInternal(generalException, errorReasonHttpStatus, null, request); } 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 index 1118bdb..dfd64a4 100644 --- a/src/main/java/com/gdg/backend/common/exception/controller/ExceptionController.java +++ b/src/main/java/com/gdg/backend/common/exception/controller/ExceptionController.java @@ -24,14 +24,17 @@ public class ExceptionController implements ErrorController { @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("message", "Unexpected error"); + 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(ErrorCode._INTERNAL_SERVER_ERROR.getCode(), "알 수 없는 에러입니다.", "ERROR"); + return ApiResponse.onFailure(String.valueOf(status), message, "ERROR"); } } \ No newline at end of file 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..cd8b9e1 --- /dev/null +++ b/src/main/java/com/gdg/backend/common/jwt/JwtTokenProvider.java @@ -0,0 +1,71 @@ +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 * 24 * 24; // 24 * 24 * 24 시간 // TODO 배포 시 변경 + + public JwtTokenProvider(@Value("${jwt.secret.key}") String secret) { + log.info("🔹 Loaded JWT Secret from Environment: {}", secret); + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); + } + + /** + * 🔑 JWT 토큰 생성 (회원 ID 기반) + */ + public String generateToken(Long memberId) { + + return Jwts.builder() + .setSubject(String.valueOf(memberId)) // 사용자 ID 저장 + .setIssuedAt(new Date()) // 발급 시간 + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 만료 시간 + .signWith(secretKey, SignatureAlgorithm.HS256) // 서명 + .compact(); + } + + public String getMemberId(String token) { + + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + /** + * 🔑 JWT 토큰 검증 및 회원 ID 반환 + */ + public Long validateToken(String token) { + try { + return Long.parseLong(Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject()); + + // TODO 토큰 검증 복구 + } catch (ExpiredJwtException e) { + log.error("❌ JWT 토큰이 만료되었습니다."); + throw new RuntimeException("JWT 토큰이 만료되었습니다."); + } catch (MalformedJwtException | SignatureException e) { + log.error("❌ 유효하지 않은 JWT 토큰입니다."); + throw new RuntimeException("유효하지 않은 JWT 토큰입니다."); + } catch (Exception e) { + log.error("❌ JWT 검증 오류: {}", e.getMessage()); + throw new RuntimeException("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/status/ErrorCode.java b/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java index ea77308..d0ed2af 100644 --- a/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java +++ b/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java @@ -22,7 +22,7 @@ public enum ErrorCode implements BaseErrorCode { NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "MEMBER4002", "닉네임은 필수 입니다."), MEMBER_SIGNUP_ERROR(HttpStatus.BAD_REQUEST, "SIGNUP4001", "회원가입 유효성 검사 실패"), - EMAIL_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "SIGNUP4002", "이미 존재하는 이메일입니다."), + EMAIL_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "SIGNUP4002", "이미 존재하는 아이디입니다."), POST_NOTFOUND(HttpStatus.BAD_REQUEST, "POST4004", "게시물을 찾을 수 없습니다."), 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..25d7d11 --- /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 index d050476..8a4bf66 100644 --- a/src/main/java/com/gdg/backend/config/SwaggerConfig.java +++ b/src/main/java/com/gdg/backend/config/SwaggerConfig.java @@ -1,7 +1,10 @@ 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; @@ -9,18 +12,34 @@ @Configuration // Spring에서 설정 클래스로 사용됨을 명시 public class SwaggerConfig { - @Bean // Spring 컨텍스트에서 OpenAPI 객체를 빈으로 등록 - public OpenAPI openApiConfig() { - + @Bean // Spring 컨텍스트에서 SchrodingerApi 메서드의 반환값을 빈으로 등록 + public OpenAPI SchrodingerApi() { // Swagger UI에서 API 문서의 정보를 설정 Info info = new Info() - .title("My API") // API의 제목 설정 - .description("This is the API documentation for My API") // API의 설명 설정 + .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 정보 추가 + .info(info) // API 정보 추가 + .addSecurityItem(securityRequirement) // 보안 요구 사항 추가 + .components(components); // 보안 스키마를 포함한 컴포넌트 추가 } } + 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 index 505d48b..d90b16a 100644 --- a/src/main/java/com/gdg/backend/domain/document/entity/Document.java +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -1,10 +1,7 @@ package com.gdg.backend.domain.document.entity; import com.gdg.backend.common.entity.BaseTimeEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -23,5 +20,6 @@ public class Document extends BaseTimeEntity { private String content; + @Version private Long version; } 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/member/controller/MemberController.java b/src/main/java/com/gdg/backend/domain/member/controller/MemberController.java new file mode 100644 index 0000000..f22eb7c --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/controller/MemberController.java @@ -0,0 +1,39 @@ +package com.gdg.backend.domain.member.controller; + +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.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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@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)); + } +} 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 index 1ea62e1..a7db277 100644 --- a/src/main/java/com/gdg/backend/domain/member/entity/Member.java +++ b/src/main/java/com/gdg/backend/domain/member/entity/Member.java @@ -1,9 +1,17 @@ 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 @@ -19,7 +27,4 @@ public abstract class Member { @Column(nullable = false) private String password; - @Column(nullable = false) - private String email; - } 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 index e1598c6..f1ed80d 100644 --- a/src/main/java/com/gdg/backend/domain/member/entity/Student.java +++ b/src/main/java/com/gdg/backend/domain/member/entity/Student.java @@ -1,10 +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..35a0b2c --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberService.java @@ -0,0 +1,11 @@ +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; + +public interface MemberService { + SignUpResponseDto register(SignUpRequestDto signUpRequestDto); + SignInResponseDto signIn(SignInRequestDto signInRequestDto); +} 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..f8b0dff --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java @@ -0,0 +1,104 @@ +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.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.entity.Teacher; +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.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class MemberServiceImpl implements MemberService { + + private final StudentRepository studentRepository; + private final TeacherRepository teacherRepository; + private final MemberRepository memberRepository; + private final CustomPasswordEncoder passwordEncoder; // final 추가 + private final JwtTokenProvider jwtTokenProvider; + + @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; + } +} 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 index 340937b..769b2c1 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -1,138 +1,138 @@ -package com.gdg.backend.domain.operation.service; - -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.operation.dto.OperationRequestDto; -import com.gdg.backend.domain.operation.dto.OperationResponseDto; -import com.gdg.backend.domain.operation.entity.Operation; -import com.gdg.backend.domain.operation.repository.OperationRepository; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; - - -/** OperationType 큐에서 주기적으로 이벤트를 가져와 처리하는 클래스 */ -@Component -@RequiredArgsConstructor -public class OperationQueueProcessor { - - private final DocumentRepository documentRepository; - private final OperationRepository operationRepository; - private final BlockingQueue operationQueue; - private final SimpMessagingTemplate template; - private final ConcurrentHashMap documentVersions = new ConcurrentHashMap<>(); - - // 문서 상태 추적 (Stringbuilder vs Document?) - private final ConcurrentHashMap documentState = new ConcurrentHashMap<>(); - - @PostConstruct - public void startProcessing() { - // 별도 스레드에서 큐를 polling 하여 처리 - new Thread(() -> { - while(true) { - try { - OperationRequestDto operation = operationQueue.take(); - processOperation(operation); - } catch (InterruptedException e) { - System.out.println("QUEUE PROCESSOR THREAD INTERRUPTED!"); - System.out.println(e.getMessage()); - break; - } - } - }).start(); - } - - /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ - @PostConstruct - public void fillDocumentVersionPool () { - List documents = documentRepository.findAll(); - Document testDoc; - Optional optionalTestDoc = documents.stream().filter((document) -> document.getId() == 1).findFirst(); - testDoc = optionalTestDoc.orElseGet(() -> { - Document doc = Document.builder() - .id(1L) - .version(0L) - .content("") - .build(); - documentRepository.save(doc); - return doc; - }); - - documents.stream().forEach(document -> { - documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); - }); - } - - private void processOperation(OperationRequestDto operation) { - // todo documentID 없는 경우 예외 처리 - - // 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)번으로 갈아타기 - - Long docId = operation.getDocumentId(); - Long baseVersion = operation.getBaseVersion(); - Long opPosition = operation.getPosition(); - List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); - for(Operation concurrentOp: concurrentOperations) { - if(concurrentOp.getOperation().equals(OperationType.INSERT) - && concurrentOp.getPosition() < opPosition) { - // 현재 operation보다 앞에 삽입한 경우 - opPosition += concurrentOp.getInsertContent().length(); - } - else if(concurrentOp.getOperation().equals(OperationType.DELETE) - && concurrentOp.getPosition() < opPosition) { - // 현재 operation보다 앞을 삭제한 경우 - opPosition -= concurrentOp.getDeleteLength(); - } - } - - // 버전 부여 - OperationResponseDto response = OperationResponseDto.of(operation); - response.setPosition(opPosition); - response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); - - // todo 서버 문서 상태에도 변경사항 가함 - - - // Operation DB에 저장 && Document version 업데이트 - // - 동기 처리 vs 비동기 처리 - // - todo 메모리에 Operation랑 Document 캐싱하기 - // - Operation은 큐 만들어서 캐싱하기 (클라이언트 ACK에 맞춰 갱신) - // - Document는 Map 형식 or Map 형식으로 저장? - operationRepository.save(Operation.builder() - .operation(response.getOperation()) - .document(documentRepository.findById(docId).orElseThrow()) // todo - .position(response.getPosition()) - .insertContent(response.getInsertContent()) - .deleteLength(response.getDeleteLength()) - .version(response.getVersion()) - .member(null) // todo - .build() - ); - - // 로그 출력 - System.out.println("Received: " + operation); - System.out.println(" 수정된 위치: " + opPosition); - System.out.println(" 수정된 버전: " + response.getVersion()); - - // 클라이언트에 브로드캐스트 - template.convertAndSend("/sub/edit/" + docId, response); - } -} +//package com.gdg.backend.domain.operation.service; +// +//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.operation.dto.OperationRequestDto; +//import com.gdg.backend.domain.operation.dto.OperationResponseDto; +//import com.gdg.backend.domain.operation.entity.Operation; +//import com.gdg.backend.domain.operation.repository.OperationRepository; +//import jakarta.annotation.PostConstruct; +//import lombok.RequiredArgsConstructor; +//import org.springframework.messaging.simp.SimpMessagingTemplate; +//import org.springframework.stereotype.Component; +// +//import java.util.List; +//import java.util.Optional; +//import java.util.concurrent.BlockingQueue; +//import java.util.concurrent.ConcurrentHashMap; +//import java.util.concurrent.atomic.AtomicLong; +// +// +///** OperationType 큐에서 주기적으로 이벤트를 가져와 처리하는 클래스 */ +//@Component +//@RequiredArgsConstructor +//public class OperationQueueProcessor { +// +// private final DocumentRepository documentRepository; +// private final OperationRepository operationRepository; +// private final BlockingQueue operationQueue; +// private final SimpMessagingTemplate template; +// private final ConcurrentHashMap documentVersions = new ConcurrentHashMap<>(); +// +// // 문서 상태 추적 (Stringbuilder vs Document?) +// private final ConcurrentHashMap documentState = new ConcurrentHashMap<>(); +// +// @PostConstruct +// public void startProcessing() { +// // 별도 스레드에서 큐를 polling 하여 처리 +// new Thread(() -> { +// while(true) { +// try { +// OperationRequestDto operation = operationQueue.take(); +// processOperation(operation); +// } catch (InterruptedException e) { +// System.out.println("QUEUE PROCESSOR THREAD INTERRUPTED!"); +// System.out.println(e.getMessage()); +// break; +// } +// } +// }).start(); +// } +// +// /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ +// @PostConstruct +// public void fillDocumentVersionPool () { +// List documents = documentRepository.findAll(); +// Document testDoc; +// Optional optionalTestDoc = documents.stream().filter((document) -> document.getId() == 1).findFirst(); +// testDoc = optionalTestDoc.orElseGet(() -> { +// Document doc = Document.builder() +// .id(1L) +// .version(0L) +// .content("") +// .build(); +// documentRepository.save(doc); +// return doc; +// }); +// +// documents.stream().forEach(document -> { +// documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); +// }); +// } +// +// private void processOperation(OperationRequestDto operation) { +// // todo documentID 없는 경우 예외 처리 +// +// // 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)번으로 갈아타기 +// +// Long docId = operation.getDocumentId(); +// Long baseVersion = operation.getBaseVersion(); +// Long opPosition = operation.getPosition(); +// List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); +// for(Operation concurrentOp: concurrentOperations) { +// if(concurrentOp.getOperation().equals(OperationType.INSERT) +// && concurrentOp.getPosition() < opPosition) { +// // 현재 operation보다 앞에 삽입한 경우 +// opPosition += concurrentOp.getInsertContent().length(); +// } +// else if(concurrentOp.getOperation().equals(OperationType.DELETE) +// && concurrentOp.getPosition() < opPosition) { +// // 현재 operation보다 앞을 삭제한 경우 +// opPosition -= concurrentOp.getDeleteLength(); +// } +// } +// +// // 버전 부여 +// OperationResponseDto response = OperationResponseDto.of(operation); +// response.setPosition(opPosition); +// response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); +// +// // todo 서버 문서 상태에도 변경사항 가함 +// +// +// // Operation DB에 저장 && Document version 업데이트 +// // - 동기 처리 vs 비동기 처리 +// // - todo 메모리에 Operation랑 Document 캐싱하기 +// // - Operation은 큐 만들어서 캐싱하기 (클라이언트 ACK에 맞춰 갱신) +// // - Document는 Map 형식 or Map 형식으로 저장? +// operationRepository.save(Operation.builder() +// .operation(response.getOperation()) +// .document(documentRepository.findById(docId).orElseThrow()) // todo +// .position(response.getPosition()) +// .insertContent(response.getInsertContent()) +// .deleteLength(response.getDeleteLength()) +// .version(response.getVersion()) +// .member(null) // todo +// .build() +// ); +// +// // 로그 출력 +// System.out.println("Received: " + operation); +// System.out.println(" 수정된 위치: " + opPosition); +// System.out.println(" 수정된 버전: " + response.getVersion()); +// +// // 클라이언트에 브로드캐스트 +// template.convertAndSend("/sub/edit/" + docId, response); +// } +//} diff --git a/src/main/resources/.gitkeep b/src/main/resources/.gitkeep deleted file mode 100644 index e69de29..0000000 From 60c9542c901384be01b57d5b1885b53736391288 Mon Sep 17 00:00:00 2001 From: JaehwanH Date: Thu, 6 Mar 2025 18:10:54 +0900 Subject: [PATCH 08/34] =?UTF-8?q?add:=20gitkeep=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A0=A4=EB=86=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/.gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/main/resources/.gitkeep 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 From 86277bfdf721b066a8b8d08ca58b8d638976844e Mon Sep 17 00:00:00 2001 From: YamYamee Date: Thu, 6 Mar 2025 23:32:06 +0900 Subject: [PATCH 09/34] =?UTF-8?q?fix:=20=EC=9D=BC=EB=B6=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/common/jwt/JwtTokenProvider.java | 43 ++- .../gdg/backend/config/SecurityConfig.java | 2 +- .../service/OperationQueueProcessor.java | 276 +++++++++--------- 3 files changed, 157 insertions(+), 164 deletions(-) diff --git a/src/main/java/com/gdg/backend/common/jwt/JwtTokenProvider.java b/src/main/java/com/gdg/backend/common/jwt/JwtTokenProvider.java index cd8b9e1..ba07050 100644 --- a/src/main/java/com/gdg/backend/common/jwt/JwtTokenProvider.java +++ b/src/main/java/com/gdg/backend/common/jwt/JwtTokenProvider.java @@ -14,28 +14,26 @@ public class JwtTokenProvider { private final SecretKey secretKey; - private static final long EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 24 * 24; // 24 * 24 * 24 시간 // TODO 배포 시 변경 + private static final long EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7; // 7일 (기본값, 필요시 변경) public JwtTokenProvider(@Value("${jwt.secret.key}") String secret) { - log.info("🔹 Loaded JWT Secret from Environment: {}", 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()); } - /** - * 🔑 JWT 토큰 생성 (회원 ID 기반) - */ public String generateToken(Long memberId) { - return Jwts.builder() - .setSubject(String.valueOf(memberId)) // 사용자 ID 저장 - .setIssuedAt(new Date()) // 발급 시간 - .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 만료 시간 - .signWith(secretKey, SignatureAlgorithm.HS256) // 서명 + .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() @@ -44,28 +42,23 @@ public String getMemberId(String token) { .getSubject(); } - /** - * 🔑 JWT 토큰 검증 및 회원 ID 반환 - */ public Long validateToken(String token) { try { - return Long.parseLong(Jwts.parserBuilder() - .setSigningKey(secretKey) - .build() - .parseClaimsJws(token) - .getBody() - .getSubject()); - - // TODO 토큰 검증 복구 + return Long.parseLong(Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject()); } catch (ExpiredJwtException e) { log.error("❌ JWT 토큰이 만료되었습니다."); - throw new RuntimeException("JWT 토큰이 만료되었습니다."); + throw new JwtException("JWT 토큰이 만료되었습니다."); } catch (MalformedJwtException | SignatureException e) { log.error("❌ 유효하지 않은 JWT 토큰입니다."); - throw new RuntimeException("유효하지 않은 JWT 토큰입니다."); + throw new JwtException("유효하지 않은 JWT 토큰입니다."); } catch (Exception e) { log.error("❌ JWT 검증 오류: {}", e.getMessage()); - throw new RuntimeException("JWT 검증 오류"); + throw new JwtException("JWT 검증 오류"); } } } diff --git a/src/main/java/com/gdg/backend/config/SecurityConfig.java b/src/main/java/com/gdg/backend/config/SecurityConfig.java index 25d7d11..8d09e77 100644 --- a/src/main/java/com/gdg/backend/config/SecurityConfig.java +++ b/src/main/java/com/gdg/backend/config/SecurityConfig.java @@ -27,7 +27,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) // H2 콘솔 허용 .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안 함 .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() // 🔥 모든 요청 허용 🔥 + .anyRequest().permitAll() // 모든 요청 허용 ) .exceptionHandling(exceptionConfig -> exceptionConfig.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) 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 index 769b2c1..340937b 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -1,138 +1,138 @@ -//package com.gdg.backend.domain.operation.service; -// -//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.operation.dto.OperationRequestDto; -//import com.gdg.backend.domain.operation.dto.OperationResponseDto; -//import com.gdg.backend.domain.operation.entity.Operation; -//import com.gdg.backend.domain.operation.repository.OperationRepository; -//import jakarta.annotation.PostConstruct; -//import lombok.RequiredArgsConstructor; -//import org.springframework.messaging.simp.SimpMessagingTemplate; -//import org.springframework.stereotype.Component; -// -//import java.util.List; -//import java.util.Optional; -//import java.util.concurrent.BlockingQueue; -//import java.util.concurrent.ConcurrentHashMap; -//import java.util.concurrent.atomic.AtomicLong; -// -// -///** OperationType 큐에서 주기적으로 이벤트를 가져와 처리하는 클래스 */ -//@Component -//@RequiredArgsConstructor -//public class OperationQueueProcessor { -// -// private final DocumentRepository documentRepository; -// private final OperationRepository operationRepository; -// private final BlockingQueue operationQueue; -// private final SimpMessagingTemplate template; -// private final ConcurrentHashMap documentVersions = new ConcurrentHashMap<>(); -// -// // 문서 상태 추적 (Stringbuilder vs Document?) -// private final ConcurrentHashMap documentState = new ConcurrentHashMap<>(); -// -// @PostConstruct -// public void startProcessing() { -// // 별도 스레드에서 큐를 polling 하여 처리 -// new Thread(() -> { -// while(true) { -// try { -// OperationRequestDto operation = operationQueue.take(); -// processOperation(operation); -// } catch (InterruptedException e) { -// System.out.println("QUEUE PROCESSOR THREAD INTERRUPTED!"); -// System.out.println(e.getMessage()); -// break; -// } -// } -// }).start(); -// } -// -// /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ -// @PostConstruct -// public void fillDocumentVersionPool () { -// List documents = documentRepository.findAll(); -// Document testDoc; -// Optional optionalTestDoc = documents.stream().filter((document) -> document.getId() == 1).findFirst(); -// testDoc = optionalTestDoc.orElseGet(() -> { -// Document doc = Document.builder() -// .id(1L) -// .version(0L) -// .content("") -// .build(); -// documentRepository.save(doc); -// return doc; -// }); -// -// documents.stream().forEach(document -> { -// documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); -// }); -// } -// -// private void processOperation(OperationRequestDto operation) { -// // todo documentID 없는 경우 예외 처리 -// -// // 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)번으로 갈아타기 -// -// Long docId = operation.getDocumentId(); -// Long baseVersion = operation.getBaseVersion(); -// Long opPosition = operation.getPosition(); -// List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); -// for(Operation concurrentOp: concurrentOperations) { -// if(concurrentOp.getOperation().equals(OperationType.INSERT) -// && concurrentOp.getPosition() < opPosition) { -// // 현재 operation보다 앞에 삽입한 경우 -// opPosition += concurrentOp.getInsertContent().length(); -// } -// else if(concurrentOp.getOperation().equals(OperationType.DELETE) -// && concurrentOp.getPosition() < opPosition) { -// // 현재 operation보다 앞을 삭제한 경우 -// opPosition -= concurrentOp.getDeleteLength(); -// } -// } -// -// // 버전 부여 -// OperationResponseDto response = OperationResponseDto.of(operation); -// response.setPosition(opPosition); -// response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); -// -// // todo 서버 문서 상태에도 변경사항 가함 -// -// -// // Operation DB에 저장 && Document version 업데이트 -// // - 동기 처리 vs 비동기 처리 -// // - todo 메모리에 Operation랑 Document 캐싱하기 -// // - Operation은 큐 만들어서 캐싱하기 (클라이언트 ACK에 맞춰 갱신) -// // - Document는 Map 형식 or Map 형식으로 저장? -// operationRepository.save(Operation.builder() -// .operation(response.getOperation()) -// .document(documentRepository.findById(docId).orElseThrow()) // todo -// .position(response.getPosition()) -// .insertContent(response.getInsertContent()) -// .deleteLength(response.getDeleteLength()) -// .version(response.getVersion()) -// .member(null) // todo -// .build() -// ); -// -// // 로그 출력 -// System.out.println("Received: " + operation); -// System.out.println(" 수정된 위치: " + opPosition); -// System.out.println(" 수정된 버전: " + response.getVersion()); -// -// // 클라이언트에 브로드캐스트 -// template.convertAndSend("/sub/edit/" + docId, response); -// } -//} +package com.gdg.backend.domain.operation.service; + +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.operation.dto.OperationRequestDto; +import com.gdg.backend.domain.operation.dto.OperationResponseDto; +import com.gdg.backend.domain.operation.entity.Operation; +import com.gdg.backend.domain.operation.repository.OperationRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + + +/** OperationType 큐에서 주기적으로 이벤트를 가져와 처리하는 클래스 */ +@Component +@RequiredArgsConstructor +public class OperationQueueProcessor { + + private final DocumentRepository documentRepository; + private final OperationRepository operationRepository; + private final BlockingQueue operationQueue; + private final SimpMessagingTemplate template; + private final ConcurrentHashMap documentVersions = new ConcurrentHashMap<>(); + + // 문서 상태 추적 (Stringbuilder vs Document?) + private final ConcurrentHashMap documentState = new ConcurrentHashMap<>(); + + @PostConstruct + public void startProcessing() { + // 별도 스레드에서 큐를 polling 하여 처리 + new Thread(() -> { + while(true) { + try { + OperationRequestDto operation = operationQueue.take(); + processOperation(operation); + } catch (InterruptedException e) { + System.out.println("QUEUE PROCESSOR THREAD INTERRUPTED!"); + System.out.println(e.getMessage()); + break; + } + } + }).start(); + } + + /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ + @PostConstruct + public void fillDocumentVersionPool () { + List documents = documentRepository.findAll(); + Document testDoc; + Optional optionalTestDoc = documents.stream().filter((document) -> document.getId() == 1).findFirst(); + testDoc = optionalTestDoc.orElseGet(() -> { + Document doc = Document.builder() + .id(1L) + .version(0L) + .content("") + .build(); + documentRepository.save(doc); + return doc; + }); + + documents.stream().forEach(document -> { + documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); + }); + } + + private void processOperation(OperationRequestDto operation) { + // todo documentID 없는 경우 예외 처리 + + // 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)번으로 갈아타기 + + Long docId = operation.getDocumentId(); + Long baseVersion = operation.getBaseVersion(); + Long opPosition = operation.getPosition(); + List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); + for(Operation concurrentOp: concurrentOperations) { + if(concurrentOp.getOperation().equals(OperationType.INSERT) + && concurrentOp.getPosition() < opPosition) { + // 현재 operation보다 앞에 삽입한 경우 + opPosition += concurrentOp.getInsertContent().length(); + } + else if(concurrentOp.getOperation().equals(OperationType.DELETE) + && concurrentOp.getPosition() < opPosition) { + // 현재 operation보다 앞을 삭제한 경우 + opPosition -= concurrentOp.getDeleteLength(); + } + } + + // 버전 부여 + OperationResponseDto response = OperationResponseDto.of(operation); + response.setPosition(opPosition); + response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); + + // todo 서버 문서 상태에도 변경사항 가함 + + + // Operation DB에 저장 && Document version 업데이트 + // - 동기 처리 vs 비동기 처리 + // - todo 메모리에 Operation랑 Document 캐싱하기 + // - Operation은 큐 만들어서 캐싱하기 (클라이언트 ACK에 맞춰 갱신) + // - Document는 Map 형식 or Map 형식으로 저장? + operationRepository.save(Operation.builder() + .operation(response.getOperation()) + .document(documentRepository.findById(docId).orElseThrow()) // todo + .position(response.getPosition()) + .insertContent(response.getInsertContent()) + .deleteLength(response.getDeleteLength()) + .version(response.getVersion()) + .member(null) // todo + .build() + ); + + // 로그 출력 + System.out.println("Received: " + operation); + System.out.println(" 수정된 위치: " + opPosition); + System.out.println(" 수정된 버전: " + response.getVersion()); + + // 클라이언트에 브로드캐스트 + template.convertAndSend("/sub/edit/" + docId, response); + } +} From c0ef28a39261fe8debca8ae06160a04d9644e7ce Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Thu, 6 Mar 2025 23:47:40 +0900 Subject: [PATCH 10/34] =?UTF-8?q?[UPDATE]=20Operation=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update: 예외처리 추가 - documentVersionPool 업데이트 시 가장 높은 id 반영하도록 변경 - concurrentOperation 처리 시 insertContent / deleteLength가 비어있는 경우 예외처리 * update: try-catch 하나 더 --- .../controller/WebsocketEventController.java | 1 + .../operation/dto/OperationRequestDto.java | 2 +- .../service/OperationQueueProcessor.java | 104 ++++++++++-------- 3 files changed, 59 insertions(+), 48 deletions(-) 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 index 15d1f4c..13e1c1a 100644 --- a/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java +++ b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java @@ -26,6 +26,7 @@ public WebsocketEventController( @MessageMapping("/edit") public void receiveEditOperation(@Valid OperationRequestDto operation) throws InterruptedException { operationQueue.put(operation); + System.out.println(operation + " put to queue"); template.convertAndSend("/sub/ack/" + operation.getDocumentId(), "ACK"); } 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 index df9d2da..1d85a7b 100644 --- a/src/main/java/com/gdg/backend/domain/operation/dto/OperationRequestDto.java +++ b/src/main/java/com/gdg/backend/domain/operation/dto/OperationRequestDto.java @@ -32,7 +32,7 @@ public class OperationRequestDto { public String toString() { StringBuilder sb = new StringBuilder(); sb.append(operation).append(" "); - if(operation.equals(OperationType.INSERT)) sb.append(insertContent + " "); + 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)); 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 index 340937b..b779478 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -45,6 +45,9 @@ public void startProcessing() { System.out.println("QUEUE PROCESSOR THREAD INTERRUPTED!"); System.out.println(e.getMessage()); break; + } catch (Exception e) { + System.out.println("QUEUE PROCESSOR UNCAUGHT EXCEPTION"); + System.out.println(e.getMessage()); } } }).start(); @@ -67,7 +70,8 @@ public void fillDocumentVersionPool () { }); documents.stream().forEach(document -> { - documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); + if(documentVersions.getOrDefault(document.getId(), new AtomicLong(-1)).get() > document.getVersion()) + documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); }); } @@ -86,53 +90,59 @@ private void processOperation(OperationRequestDto operation) { // - queue로 구현해서, 클라이언트 ACK 받을 시 queue에서 옛날 event pop / 새로운 event 받을 시 queue에 push // -> 클라이언트 ACK 추적 기능 구현 되면 (2)번으로 갈아타기 - Long docId = operation.getDocumentId(); - Long baseVersion = operation.getBaseVersion(); - Long opPosition = operation.getPosition(); - List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); - for(Operation concurrentOp: concurrentOperations) { - if(concurrentOp.getOperation().equals(OperationType.INSERT) - && concurrentOp.getPosition() < opPosition) { - // 현재 operation보다 앞에 삽입한 경우 - opPosition += concurrentOp.getInsertContent().length(); - } - else if(concurrentOp.getOperation().equals(OperationType.DELETE) - && concurrentOp.getPosition() < opPosition) { - // 현재 operation보다 앞을 삭제한 경우 - opPosition -= concurrentOp.getDeleteLength(); + try { + Long docId = operation.getDocumentId(); + Long baseVersion = operation.getBaseVersion(); + Long opPosition = operation.getPosition(); + List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); + for (Operation concurrentOp : concurrentOperations) { + if (concurrentOp.getOperation().equals(OperationType.INSERT) + && concurrentOp.getPosition() < opPosition) { + // 현재 operation보다 앞에 삽입한 경우 + if(concurrentOp.getInsertContent() == null) continue;; + opPosition += concurrentOp.getInsertContent().length(); + } else if (concurrentOp.getOperation().equals(OperationType.DELETE) + && concurrentOp.getPosition() < opPosition) { + // 현재 operation보다 앞을 삭제한 경우 + if(concurrentOp.getDeleteLength() == null) continue;; + opPosition -= concurrentOp.getDeleteLength(); + } } - } - // 버전 부여 - OperationResponseDto response = OperationResponseDto.of(operation); - response.setPosition(opPosition); - response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); - - // todo 서버 문서 상태에도 변경사항 가함 - - - // Operation DB에 저장 && Document version 업데이트 - // - 동기 처리 vs 비동기 처리 - // - todo 메모리에 Operation랑 Document 캐싱하기 - // - Operation은 큐 만들어서 캐싱하기 (클라이언트 ACK에 맞춰 갱신) - // - Document는 Map 형식 or Map 형식으로 저장? - operationRepository.save(Operation.builder() - .operation(response.getOperation()) - .document(documentRepository.findById(docId).orElseThrow()) // todo - .position(response.getPosition()) - .insertContent(response.getInsertContent()) - .deleteLength(response.getDeleteLength()) - .version(response.getVersion()) - .member(null) // todo - .build() - ); - - // 로그 출력 - System.out.println("Received: " + operation); - System.out.println(" 수정된 위치: " + opPosition); - System.out.println(" 수정된 버전: " + response.getVersion()); - - // 클라이언트에 브로드캐스트 - template.convertAndSend("/sub/edit/" + docId, response); + // 버전 부여 + OperationResponseDto response = OperationResponseDto.of(operation); + response.setPosition(opPosition); + response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); + + // todo 서버 문서 상태에도 변경사항 가함 + + + // Operation DB에 저장 && Document version 업데이트 + // - 동기 처리 vs 비동기 처리 + // - todo 메모리에 Operation랑 Document 캐싱하기 + // - Operation은 큐 만들어서 캐싱하기 (클라이언트 ACK에 맞춰 갱신) + // - Document는 Map 형식 or Map 형식으로 저장? + operationRepository.save(Operation.builder() + .operation(response.getOperation()) + .document(documentRepository.findById(docId).orElseThrow()) // todo + .position(response.getPosition()) + .insertContent(response.getInsertContent()) + .deleteLength(response.getDeleteLength()) + .version(response.getVersion()) + .member(null) // todo + .build() + ); + + // 로그 출력 + System.out.println("Received: " + operation); + System.out.println(" 수정된 위치: " + opPosition); + System.out.println(" 수정된 버전: " + response.getVersion()); + + // 클라이언트에 브로드캐스트 + template.convertAndSend("/sub/edit/" + docId, response); + } catch (Exception e) { + System.out.println("Exception while handling operation " + operation); + System.out.println(e.getMessage()); + } } } From ff4af3ed475c13987d77238b1117feaca510b227 Mon Sep 17 00:00:00 2001 From: YamYamee Date: Fri, 7 Mar 2025 10:41:42 +0900 Subject: [PATCH 11/34] =?UTF-8?q?add:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EA=B4=80=EB=A0=A8=20api=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/response/status/ErrorCode.java | 3 + .../common/util/RandomCodeGenerator.java | 18 ++++++ .../com/gdg/backend/config/WebConfig.java | 13 +++++ .../controller/ClassroomController.java | 35 +++++++++++ .../domain/classroom/entity/Classroom.java | 24 ++++++++ .../repository/ClassroomRepository.java | 12 ++++ .../classroom/service/ClassroomService.java | 11 ++++ .../service/ClassroomServiceImpl.java | 58 +++++++++++++++++++ .../backend/domain/course/entity/Course.java | 1 - .../domain/invitation/entity/Invitation.java | 9 +++ .../repository/InvitationRepository.java | 13 +++++ .../member/controller/MemberController.java | 16 +++-- .../backend/domain/member/dto/CourseInfo.java | 24 ++++++++ .../domain/member/dto/DashBoardInfoDto.java | 26 +++++++++ .../backend/domain/member/entity/Member.java | 2 - .../domain/member/service/MemberService.java | 2 + .../member/service/MemberServiceImpl.java | 47 +++++++++++++-- 17 files changed, 301 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/gdg/backend/common/util/RandomCodeGenerator.java create mode 100644 src/main/java/com/gdg/backend/domain/classroom/controller/ClassroomController.java create mode 100644 src/main/java/com/gdg/backend/domain/classroom/repository/ClassroomRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/classroom/service/ClassroomService.java create mode 100644 src/main/java/com/gdg/backend/domain/classroom/service/ClassroomServiceImpl.java create mode 100644 src/main/java/com/gdg/backend/domain/invitation/repository/InvitationRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/member/dto/CourseInfo.java create mode 100644 src/main/java/com/gdg/backend/domain/member/dto/DashBoardInfoDto.java 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 index d0ed2af..fc804e6 100644 --- a/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java +++ b/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java @@ -28,6 +28,9 @@ public enum ErrorCode implements BaseErrorCode { UNSIGNED(HttpStatus.BAD_REQUEST, "POST4001", "로그인 되어 있지 않습니다."), + INVALID_CODE(HttpStatus.BAD_REQUEST, "CLASS4000", "유효하지 않은 코드입니다."), + CLASS_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "CLASS4001", "이미 존재하는 강의실명입니다."), + ; 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/WebConfig.java b/src/main/java/com/gdg/backend/config/WebConfig.java index 07e6818..60475eb 100644 --- a/src/main/java/com/gdg/backend/config/WebConfig.java +++ b/src/main/java/com/gdg/backend/config/WebConfig.java @@ -1,12 +1,25 @@ 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("/**") 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..3078d7b --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/controller/ClassroomController.java @@ -0,0 +1,35 @@ +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.entity.Classroom; +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)); + } + +} 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 index f172930..35f8be4 100644 --- a/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java +++ b/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java @@ -1,15 +1,23 @@ 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 @@ -18,6 +26,11 @@ public class Classroom { private String name; // 강의실 이름 + private String invitationCode; + + @OneToOne + private Teacher teacher; + @OneToMany(mappedBy = "classroom") private List courses; @@ -26,4 +39,15 @@ public class Classroom { @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..04e7659 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/repository/ClassroomRepository.java @@ -0,0 +1,12 @@ +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.Optional; + +public interface ClassroomRepository extends JpaRepository { + Optional findByInvitationCode(String invitationCode); + Boolean existsByTeacherAndName(Teacher teacher, String name); +} 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..24cba6f --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomService.java @@ -0,0 +1,11 @@ +package com.gdg.backend.domain.classroom.service; + +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); +} 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..eb94e96 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomServiceImpl.java @@ -0,0 +1,58 @@ +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.classroom.entity.Classroom; +import com.gdg.backend.domain.classroom.repository.ClassroomRepository; +import com.gdg.backend.domain.invitation.entity.Invitation; +import com.gdg.backend.domain.invitation.repository.InvitationRepository; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.gdg.backend.common.util.RandomCodeGenerator.getRandomCode; + +@Service +@RequiredArgsConstructor +public class ClassroomServiceImpl implements ClassroomService { + + private final ClassroomRepository classroomRepository; + private final InvitationRepository invitationRepository; + + @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); + + return randomCode; + } + + @Override + public String enterClassroom(String code, Member member) { + + // 유효하지 않은 코드인 경우 + Classroom classroom = classroomRepository.findByInvitationCode(code).orElseThrow(()-> new GeneralHandler(ErrorCode.INVALID_CODE)); + + if(member instanceof Student) { + Invitation invitation = new Invitation(member, classroom); + invitationRepository.save(invitation); + } else { + throw new GeneralHandler(ErrorCode._FORBIDDEN); + } + + return "success"; + } + +} 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 index 874ecb0..ec509d2 100644 --- a/src/main/java/com/gdg/backend/domain/course/entity/Course.java +++ b/src/main/java/com/gdg/backend/domain/course/entity/Course.java @@ -22,7 +22,6 @@ public class Course { @JoinColumn(name = "classroom_id") private Classroom classroom; - @OneToMany(mappedBy = "course") private List attendances; 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 index b161e05..3a776b0 100644 --- a/src/main/java/com/gdg/backend/domain/invitation/entity/Invitation.java +++ b/src/main/java/com/gdg/backend/domain/invitation/entity/Invitation.java @@ -4,10 +4,14 @@ 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) @@ -21,4 +25,9 @@ public class Invitation extends BaseTimeEntity { @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..47e3a3f --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/invitation/repository/InvitationRepository.java @@ -0,0 +1,13 @@ +package com.gdg.backend.domain.invitation.repository; + +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); + +} 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 index f22eb7c..0b90d9a 100644 --- a/src/main/java/com/gdg/backend/domain/member/controller/MemberController.java +++ b/src/main/java/com/gdg/backend/domain/member/controller/MemberController.java @@ -1,10 +1,12 @@ 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; @@ -12,10 +14,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @@ -36,4 +35,13 @@ public ApiResponse signupStudent(@RequestBody @Valid SignUpRe 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..66f3fa6 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/member/dto/CourseInfo.java @@ -0,0 +1,24 @@ +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; + } + + @Getter + @Setter + @AllArgsConstructor + public static class StudentCourseInfo { + private String teacherName; + private String courseName; + } +} 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/entity/Member.java b/src/main/java/com/gdg/backend/domain/member/entity/Member.java index a7db277..fe48d48 100644 --- a/src/main/java/com/gdg/backend/domain/member/entity/Member.java +++ b/src/main/java/com/gdg/backend/domain/member/entity/Member.java @@ -13,7 +13,6 @@ @NoArgsConstructor @AllArgsConstructor public abstract class Member { - @Id @GeneratedValue private Long id; @@ -26,5 +25,4 @@ public abstract class Member { @Column(nullable = false) private String password; - } 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 index 35a0b2c..2a9538a 100644 --- a/src/main/java/com/gdg/backend/domain/member/service/MemberService.java +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberService.java @@ -4,8 +4,10 @@ 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 index f8b0dff..92fa552 100644 --- a/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java @@ -4,21 +4,21 @@ 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.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.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.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.util.List; + @Service @Slf4j @RequiredArgsConstructor @@ -27,8 +27,9 @@ public class MemberServiceImpl implements MemberService { private final StudentRepository studentRepository; private final TeacherRepository teacherRepository; private final MemberRepository memberRepository; - private final CustomPasswordEncoder passwordEncoder; // final 추가 + private final CustomPasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final InvitationRepository invitationRepository; @Override public SignUpResponseDto register(SignUpRequestDto signUpRequestDto) { @@ -101,4 +102,38 @@ public SignInResponseDto signIn(SignInRequestDto signInRequestDto) { 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().getName(), invitation.getClassroom().getTeacher().getUsername())); + }); + + return new DashBoardInfoDto.StudentDashBoardInfoDto(member.getUsername(), studentCourseInfos); + + } else if (member instanceof Teacher) { + + List invitations = invitationRepository.findAllByMember(member); + + List teacherCourseInfos = new java.util.ArrayList<>(List.of()); + + invitations.forEach(invitation -> { + teacherCourseInfos.add(new CourseInfo.TeacherCourseInfo(invitation.getClassroom().getName(), invitation.getClassroom().getInvitationCode())); + }); + + return new DashBoardInfoDto.TeacherDashBoardInfoDto(member.getUsername(), teacherCourseInfos); + + } else { + throw new IllegalArgumentException("Unknown member type"); + } + } } From d15150dc8585982a5e2e69f88a8e1655ebd685c4 Mon Sep 17 00:00:00 2001 From: YamYamee Date: Fri, 7 Mar 2025 11:12:18 +0900 Subject: [PATCH 12/34] =?UTF-8?q?fix:=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gdg/backend/domain/classroom/entity/Classroom.java | 2 +- .../domain/classroom/repository/ClassroomRepository.java | 2 ++ .../backend/domain/member/service/MemberServiceImpl.java | 9 ++++++--- 3 files changed, 9 insertions(+), 4 deletions(-) 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 index 35f8be4..10cd71a 100644 --- a/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java +++ b/src/main/java/com/gdg/backend/domain/classroom/entity/Classroom.java @@ -28,7 +28,7 @@ public class Classroom { private String invitationCode; - @OneToOne + @ManyToOne private Teacher teacher; @OneToMany(mappedBy = "classroom") 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 index 04e7659..841275b 100644 --- a/src/main/java/com/gdg/backend/domain/classroom/repository/ClassroomRepository.java +++ b/src/main/java/com/gdg/backend/domain/classroom/repository/ClassroomRepository.java @@ -4,9 +4,11 @@ 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/member/service/MemberServiceImpl.java b/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java index 92fa552..f280969 100644 --- a/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java @@ -4,6 +4,8 @@ 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; @@ -30,6 +32,7 @@ public class MemberServiceImpl implements MemberService { private final CustomPasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; private final InvitationRepository invitationRepository; + private final ClassroomRepository classroomRepository; @Override public SignUpResponseDto register(SignUpRequestDto signUpRequestDto) { @@ -122,12 +125,12 @@ public Object getDashboardInfo(Member member) { } else if (member instanceof Teacher) { - List invitations = invitationRepository.findAllByMember(member); + List classrooms = classroomRepository.findAllByTeacher((Teacher) member); List teacherCourseInfos = new java.util.ArrayList<>(List.of()); - invitations.forEach(invitation -> { - teacherCourseInfos.add(new CourseInfo.TeacherCourseInfo(invitation.getClassroom().getName(), invitation.getClassroom().getInvitationCode())); + classrooms.forEach(classroom -> { + teacherCourseInfos.add(new CourseInfo.TeacherCourseInfo(classroom.getInvitationCode(), classroom.getName())); }); return new DashBoardInfoDto.TeacherDashBoardInfoDto(member.getUsername(), teacherCourseInfos); From ed3c37c6bd8b53708b3bd861b15e54bbf188506a Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Sun, 9 Mar 2025 04:49:24 +0900 Subject: [PATCH 13/34] =?UTF-8?q?[HOTFIX]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20DocumentVersion=20NullPointerException=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update: 예외처리 추가 - documentVersionPool 업데이트 시 가장 높은 id 반영하도록 변경 - concurrentOperation 처리 시 insertContent / deleteLength가 비어있는 경우 예외처리 * update: try-catch 하나 더 * hotfix: 테스트 문서 DocumentVersion null 오류 해결 * update: 테스트문서 operation 로그도 삭제하도록 함 --- .../domain/document/entity/Document.java | 13 +++++---- .../repository/OperationRepository.java | 2 ++ .../service/OperationQueueProcessor.java | 29 +++++++++++-------- 3 files changed, 26 insertions(+), 18 deletions(-) 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 index d90b16a..174157e 100644 --- a/src/main/java/com/gdg/backend/domain/document/entity/Document.java +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -1,11 +1,11 @@ package com.gdg.backend.domain.document.entity; import com.gdg.backend.common.entity.BaseTimeEntity; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.*; @Entity @Getter @@ -18,8 +18,9 @@ public class Document extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Setter private String content; - @Version + @Setter 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 index 729093f..01851af 100644 --- a/src/main/java/com/gdg/backend/domain/operation/repository/OperationRepository.java +++ b/src/main/java/com/gdg/backend/domain/operation/repository/OperationRepository.java @@ -10,5 +10,7 @@ public interface OperationRepository extends JpaRepository { List findByDocumentIdAndVersionGreaterThan(Long documentId, 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 index b779478..a90f31c 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -56,19 +56,24 @@ public void startProcessing() { /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ @PostConstruct public void fillDocumentVersionPool () { - List documents = documentRepository.findAll(); - Document testDoc; - Optional optionalTestDoc = documents.stream().filter((document) -> document.getId() == 1).findFirst(); - testDoc = optionalTestDoc.orElseGet(() -> { - Document doc = Document.builder() - .id(1L) - .version(0L) - .content("") - .build(); - documentRepository.save(doc); - return doc; - }); + try { + // (임시) 테스트 문서 초기화 + final Long TEST_DOC_ID = 1L; + Document testDoc = documentRepository.findById(TEST_DOC_ID) + .orElse(Document.builder() + .id(1L) + .build()); + testDoc.setVersion(0L); + testDoc.setContent(""); + documentRepository.save(testDoc); + // (임시) 테스트 문서 operation 로그 초기화 + operationRepository.deleteByDocumentId(TEST_DOC_ID); + } catch (Exception e) { + System.out.println("Exception while initializing OperationQueueProcessor"); + System.out.println(e.getMessage()); + } + List documents = documentRepository.findAll(); documents.stream().forEach(document -> { if(documentVersions.getOrDefault(document.getId(), new AtomicLong(-1)).get() > document.getVersion()) documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); From ff22785ac2c70a57cedb23dd170f8b808fdb1906 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Sun, 9 Mar 2025 20:54:44 +0900 Subject: [PATCH 14/34] =?UTF-8?q?hotfix:=20fillDocumentVersionPool=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/operation/service/OperationQueueProcessor.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index a90f31c..a903b8a 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -13,7 +13,6 @@ import org.springframework.stereotype.Component; import java.util.List; -import java.util.Optional; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -75,9 +74,10 @@ public void fillDocumentVersionPool () { List documents = documentRepository.findAll(); documents.stream().forEach(document -> { - if(documentVersions.getOrDefault(document.getId(), new AtomicLong(-1)).get() > document.getVersion()) + if(document.getVersion() > documentVersions.getOrDefault(document.getId(), new AtomicLong(-1)).get()) documentVersions.put(document.getId(), new AtomicLong(document.getVersion())); }); + System.out.println("FILLED DOCUMENT POOL: " + documentVersions); } private void processOperation(OperationRequestDto operation) { From 22d357b40f4d13faadbd9b5fa0deb72676a7eb62 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Mon, 10 Mar 2025 00:08:29 +0900 Subject: [PATCH 15/34] =?UTF-8?q?[FIX]=20OperationResponseDto=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=84=A4=EC=A0=95=20=EC=98=A4=EB=A5=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gdg/backend/domain/operation/dto/OperationResponseDto.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7f81562..0092ecb 100644 --- a/src/main/java/com/gdg/backend/domain/operation/dto/OperationResponseDto.java +++ b/src/main/java/com/gdg/backend/domain/operation/dto/OperationResponseDto.java @@ -23,7 +23,7 @@ public static OperationResponseDto of(OperationRequestDto request) { response.setOperation(request.getOperation()); response.setDocumentId(request.getDocumentId()); response.setInsertContent(request.getInsertContent()); - response.setDeleteLength(response.getDeleteLength()); + response.setDeleteLength(request.getDeleteLength()); response.setPosition(request.getPosition()); response.setVersion(request.getBaseVersion()); response.setUserId(request.getUserId()); From 76cb4bc1494d1ff766475b9df3d0401812a746a7 Mon Sep 17 00:00:00 2001 From: YamYamee Date: Tue, 11 Mar 2025 21:55:43 +0900 Subject: [PATCH 16/34] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gdg/backend/domain/member/service/MemberServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f280969..26b8eac 100644 --- a/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java @@ -118,7 +118,7 @@ public Object getDashboardInfo(Member member) { List studentCourseInfos = new java.util.ArrayList<>(List.of()); invitations.forEach(invitation -> { - studentCourseInfos.add(new CourseInfo.StudentCourseInfo(invitation.getClassroom().getName(), invitation.getClassroom().getTeacher().getUsername())); + studentCourseInfos.add(new CourseInfo.StudentCourseInfo(invitation.getClassroom().getTeacher().getUsername(), invitation.getClassroom().getName())); }); return new DashBoardInfoDto.StudentDashBoardInfoDto(member.getUsername(), studentCourseInfos); From 174efb5b127dbdb403f5dc2ccff40e068d40b1b1 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Wed, 12 Mar 2025 02:12:28 +0900 Subject: [PATCH 17/34] =?UTF-8?q?[FEAT]=20@TrackExecutionTime=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Member id에 generation strategy 추가 * add: annotation 추가 * add: log 추가 * add: @Slf4j로변경 * update: Pointcut에 SimpMessagingTemplate 추가 --- .../common/annotation/TrackExecutionTime.java | 11 +++++++ .../aspect/ExecutionTimeAspect.java | 30 +++++++++++++++++++ .../backend/domain/member/entity/Member.java | 2 +- .../operation/dto/OperationResponseDto.java | 11 +++++++ .../service/OperationQueueProcessor.java | 16 ++++++++-- 5 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/gdg/backend/common/annotation/TrackExecutionTime.java create mode 100644 src/main/java/com/gdg/backend/common/annotation/aspect/ExecutionTimeAspect.java 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/domain/member/entity/Member.java b/src/main/java/com/gdg/backend/domain/member/entity/Member.java index fe48d48..af7a3b3 100644 --- a/src/main/java/com/gdg/backend/domain/member/entity/Member.java +++ b/src/main/java/com/gdg/backend/domain/member/entity/Member.java @@ -14,7 +14,7 @@ @AllArgsConstructor public abstract class Member { @Id - @GeneratedValue + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) 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 index 0092ecb..996243c 100644 --- a/src/main/java/com/gdg/backend/domain/operation/dto/OperationResponseDto.java +++ b/src/main/java/com/gdg/backend/domain/operation/dto/OperationResponseDto.java @@ -29,4 +29,15 @@ public static OperationResponseDto of(OperationRequestDto request) { 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/service/OperationQueueProcessor.java b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java index a903b8a..0032fb1 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -1,5 +1,6 @@ package com.gdg.backend.domain.operation.service; +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; @@ -9,9 +10,13 @@ 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.stereotype.Component; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; @@ -19,6 +24,7 @@ /** OperationType 큐에서 주기적으로 이벤트를 가져와 처리하는 클래스 */ +@Slf4j @Component @RequiredArgsConstructor public class OperationQueueProcessor { @@ -80,6 +86,7 @@ public void fillDocumentVersionPool () { System.out.println("FILLED DOCUMENT POOL: " + documentVersions); } + @TrackExecutionTime private void processOperation(OperationRequestDto operation) { // todo documentID 없는 경우 예외 처리 @@ -96,6 +103,11 @@ private void processOperation(OperationRequestDto operation) { // -> 클라이언트 ACK 추적 기능 구현 되면 (2)번으로 갈아타기 try { + // 로그 출력 + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); + log.info("{} Received: {}", now.format(formatter), operation); + Long docId = operation.getDocumentId(); Long baseVersion = operation.getBaseVersion(); Long opPosition = operation.getPosition(); @@ -139,9 +151,7 @@ private void processOperation(OperationRequestDto operation) { ); // 로그 출력 - System.out.println("Received: " + operation); - System.out.println(" 수정된 위치: " + opPosition); - System.out.println(" 수정된 버전: " + response.getVersion()); + log.info(" 수정된 Operation: {}", response); // 클라이언트에 브로드캐스트 template.convertAndSend("/sub/edit/" + docId, response); From bc445ae0221aca90f09c7bcd5f764457557fbb44 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Wed, 12 Mar 2025 02:22:51 +0900 Subject: [PATCH 18/34] =?UTF-8?q?[FEAT]=20=EB=AC=B8=EC=84=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=BA=90=EC=8B=B1=20&=20SYNC=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add: 에러코드 추가 * add: Document 아래 contentBuilder 필드 추가 * feat: 문서 상태 캐싱 * comment: 주석 및 코드 스타일 수정 * feat: 변경된 문서 백그라운드에서 저장 * fix: processOperation에서 dirtyDocument 추가 * update: dirty document 저장 시 로그 출력 * update: Document 엔티티 content <-> contentBuilder 동기화 안정성을 위한 메서드 추가 * add: SYNC 추가 * feat: SYNC 수신 시 문서 상태 브로드캐스트 * rename: ret 대신 docContent --- .../com/gdg/backend/BackendApplication.java | 2 + .../common/response/status/ErrorCode.java | 2 + .../domain/document/entity/Document.java | 36 ++++- .../backend/domain/enums/OperationType.java | 3 +- .../service/OperationQueueProcessor.java | 127 +++++++++++++----- 5 files changed, 132 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/gdg/backend/BackendApplication.java b/src/main/java/com/gdg/backend/BackendApplication.java index 36ca53b..2cb3087 100644 --- a/src/main/java/com/gdg/backend/BackendApplication.java +++ b/src/main/java/com/gdg/backend/BackendApplication.java @@ -4,9 +4,11 @@ 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) { 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 index fc804e6..e715df2 100644 --- a/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java +++ b/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java @@ -31,6 +31,8 @@ public enum ErrorCode implements BaseErrorCode { INVALID_CODE(HttpStatus.BAD_REQUEST, "CLASS4000", "유효하지 않은 코드입니다."), CLASS_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "CLASS4001", "이미 존재하는 강의실명입니다."), + // 문서 관련 에러 + DOCUMENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "DOCUMENT4001", "문서를 찾을 수 없습니다."), ; 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 index 174157e..b50147b 100644 --- a/src/main/java/com/gdg/backend/domain/document/entity/Document.java +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -1,10 +1,7 @@ package com.gdg.backend.domain.document.entity; import com.gdg.backend.common.entity.BaseTimeEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; import lombok.*; @Entity @@ -23,4 +20,35 @@ public class Document extends BaseTimeEntity { @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() { + this.contentBuilder = new StringBuilder(content != null ? content : ""); + } + + // INSERT, UPDATE 전 contentBuilder -> content 동기화 + @PrePersist + @PreUpdate + public void syncContentBeforeSave() { + if (contentBuilder != null) { + content = contentBuilder.toString(); + } + } } diff --git a/src/main/java/com/gdg/backend/domain/enums/OperationType.java b/src/main/java/com/gdg/backend/domain/enums/OperationType.java index 3e55a47..75bb277 100644 --- a/src/main/java/com/gdg/backend/domain/enums/OperationType.java +++ b/src/main/java/com/gdg/backend/domain/enums/OperationType.java @@ -4,5 +4,6 @@ public enum OperationType { INSERT, DELETE, UPDATE, - CURSOR + CURSOR, + SYNC } \ No newline at end of file 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 index 0032fb1..09df263 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -1,5 +1,7 @@ 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; @@ -12,12 +14,16 @@ 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.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -35,8 +41,11 @@ public class OperationQueueProcessor { private final SimpMessagingTemplate template; private final ConcurrentHashMap documentVersions = new ConcurrentHashMap<>(); - // 문서 상태 추적 (Stringbuilder vs Document?) - private final ConcurrentHashMap documentState = new ConcurrentHashMap<>(); + // 문서 상태 캐싱 + // - todo 문서 많아지면 OutOfMemory 발생 가능 -> 추후에 LRU나 TTL 설정 + private final ConcurrentHashMap documentCache = new ConcurrentHashMap<>(); + + private final Set dirtyDocuments = new HashSet<>(); // 변경된 Document 추적 (주기적으로 저장) @PostConstruct public void startProcessing() { @@ -47,22 +56,25 @@ public void startProcessing() { OperationRequestDto operation = operationQueue.take(); processOperation(operation); } catch (InterruptedException e) { - System.out.println("QUEUE PROCESSOR THREAD INTERRUPTED!"); - System.out.println(e.getMessage()); + log.error("QUEUE PROCESSOR THREAD INTERRUPTED: {}", e.getMessage()); break; } catch (Exception e) { - System.out.println("QUEUE PROCESSOR UNCAUGHT EXCEPTION"); - System.out.println(e.getMessage()); + log.error("QUEUE PROCESSOR UNCAUGHT EXCEPTION: {}", e.getMessage()); } } }).start(); } - /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ @PostConstruct - public void fillDocumentVersionPool () { + public void postConstructJob() { + createTestDocument(); + fillDocumentPool(); + fillDocumentVersionPool(); + } + + /** (임시) 테스트 문서 초기화 -> 다른 PostConstruct 메소드보다 먼저 호출되어야 함 */ + private void createTestDocument() { try { - // (임시) 테스트 문서 초기화 final Long TEST_DOC_ID = 1L; Document testDoc = documentRepository.findById(TEST_DOC_ID) .orElse(Document.builder() @@ -74,52 +86,75 @@ public void fillDocumentVersionPool () { // (임시) 테스트 문서 operation 로그 초기화 operationRepository.deleteByDocumentId(TEST_DOC_ID); } catch (Exception e) { - System.out.println("Exception while initializing OperationQueueProcessor"); - System.out.println(e.getMessage()); + log.error("Exception while creating test document: {}", e.getMessage()); } + } + /** DB에서 Document fetch해서 메모리로 가져옴 */ +// @PostConstruct + public void fillDocumentPool() { + List documents = documentRepository.findAll(); + for(Document doc : documents) { + documentCache.put(doc.getId(), doc); + } + } + + /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ +// @PostConstruct + 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())); }); - System.out.println("FILLED DOCUMENT POOL: " + documentVersions); + log.info("FILLED DOCUMENT POOL: {}", documentVersions); } @TrackExecutionTime - private void processOperation(OperationRequestDto operation) { - // todo documentID 없는 경우 예외 처리 + public void processOperation(OperationRequestDto operation) { + Long docId = operation.getDocumentId(); + Long baseVersion = operation.getBaseVersion(); + Long opPosition = operation.getPosition(); + + // documentID 없는 경우 예외처리 (캐시엔 없지만 DB에 있는 경우 캐시 업데이트) + Document doc = documentCache.computeIfAbsent(docId, id -> documentRepository.findById(docId) + .orElseThrow(() -> new GeneralHandler(ErrorCode.DOCUMENT_NOT_FOUND)) + ); + + // SYNC인 경우 따로 처리 + // - 현재 문서 상태 브로드캐스팅 + // - version을 높이지 않음 + // - 추후 전략 패턴 등으로 추상화 + if(operation.getOperation().equals(OperationType.SYNC)) { + log.info("Received: SYNC"); + String docContent = doc.getContentBuilder().toString(); + template.convertAndSend("/sub/edit/" + docId, docContent); + return; + } // operation 충돌 시 변환 처리 // - operation의 baseVersion과 서버가 추적하는 version을 비교 - // - 차이나는 version만큼 position을 업데이트한다 (insert: position 증가 / delete: position 감소) + // - 차이나는 version만큼 position을 업데이트한다 (insert: position 증가 / delete: position 감소) // - 이전 version의 이벤트 추적 방법 // - (1) DB에서 가져온다 -> 구현이 쉬우니까 일단 이걸로 감 // - 대신 DB 가져오는 시간이 너무 오래 걸릴 거임 // - (2) 메모리에 킵한다 -> 얼마나 킵할지 알 수 없음 (전부 킵하면 결국 OutOfMemory 뜰거임) - // - 연결된 클라들이 어느 version까지 받았는지 추적할 수 있으면 메모리 할당량 조절 가능 + // - 연결된 클라들이 어느 version까지 받았는지 추적하면 메모리 할당량 조절 가능 // - 클라가 전부 version 11까지는 받았다 -> version 10 이상은 메모리에서 해제 // - queue로 구현해서, 클라이언트 ACK 받을 시 queue에서 옛날 event pop / 새로운 event 받을 시 queue에 push // -> 클라이언트 ACK 추적 기능 구현 되면 (2)번으로 갈아타기 - try { // 로그 출력 ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); log.info("{} Received: {}", now.format(formatter), operation); - - Long docId = operation.getDocumentId(); - Long baseVersion = operation.getBaseVersion(); - Long opPosition = operation.getPosition(); List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); for (Operation concurrentOp : concurrentOperations) { - if (concurrentOp.getOperation().equals(OperationType.INSERT) - && concurrentOp.getPosition() < opPosition) { + if (concurrentOp.getOperation().equals(OperationType.INSERT) && concurrentOp.getPosition() < opPosition) { // 현재 operation보다 앞에 삽입한 경우 if(concurrentOp.getInsertContent() == null) continue;; opPosition += concurrentOp.getInsertContent().length(); - } else if (concurrentOp.getOperation().equals(OperationType.DELETE) - && concurrentOp.getPosition() < opPosition) { + } else if (concurrentOp.getOperation().equals(OperationType.DELETE) && concurrentOp.getPosition() < opPosition) { // 현재 operation보다 앞을 삭제한 경우 if(concurrentOp.getDeleteLength() == null) continue;; opPosition -= concurrentOp.getDeleteLength(); @@ -131,17 +166,22 @@ private void processOperation(OperationRequestDto operation) { response.setPosition(opPosition); response.setVersion(documentVersions.get(operation.getDocumentId()).incrementAndGet()); - // todo 서버 문서 상태에도 변경사항 가함 - + // 문서 상태 갱신 + int idx = Math.toIntExact(opPosition); + switch(operation.getOperation()) { + case INSERT -> doc.getContentBuilder().insert(idx, operation.getInsertContent()); + case DELETE -> doc.getContentBuilder().delete(idx, operation.getDeleteLength()); + } + dirtyDocuments.add(docId); + doc.syncContentBuilder(); // Operation DB에 저장 && Document version 업데이트 // - 동기 처리 vs 비동기 처리 - // - todo 메모리에 Operation랑 Document 캐싱하기 - // - Operation은 큐 만들어서 캐싱하기 (클라이언트 ACK에 맞춰 갱신) - // - Document는 Map 형식 or Map 형식으로 저장? + // - todo 메모리에 Operation 캐싱하기 + // - Operation 큐 만들어서 캐싱하기 (클라이언트 ACK에 맞춰 갱신) operationRepository.save(Operation.builder() .operation(response.getOperation()) - .document(documentRepository.findById(docId).orElseThrow()) // todo + .document(doc) .position(response.getPosition()) .insertContent(response.getInsertContent()) .deleteLength(response.getDeleteLength()) @@ -156,8 +196,29 @@ private void processOperation(OperationRequestDto operation) { // 클라이언트에 브로드캐스트 template.convertAndSend("/sub/edit/" + docId, response); } catch (Exception e) { - System.out.println("Exception while handling operation " + operation); - System.out.println(e.getMessage()); + log.error("Exception while handling operation {}: {}", operation, e.getMessage()); + } + } + + /** 주기적으로 변경된 문서 저장
+ * - DB 쓰기는 네트워크 요청 + 디스크 I/O를 포함하므로 무거움 + * - processOperation에서 제거해서 실행시간 줄여서 사용자 경험 증가 & 트랜잭션 부하 감소 + * - 실시간성은 documentCache로 유지하고 저장은 백그라운드에서 주기적으로 진행 + * */ + @Scheduled(fixedRate = 10000) // 10초마다 실행 + @Transactional + public void saveDirtyDocuments() { + // 로그 출력 + if(!dirtyDocuments.isEmpty()) System.out.println("SAVING DIRTY DOCUMENTS (id=" + dirtyDocuments + ")"); + for (Long docId : dirtyDocuments) { + Document doc = documentCache.get(docId); + if (doc != null) { + synchronized (doc) { + doc.syncContentBuilder(); + documentRepository.save(doc); + } + } } + dirtyDocuments.clear(); } } From 651e9f81a901f9d8d10ad3170e43cb11188e16d3 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Wed, 12 Mar 2025 12:52:06 +0900 Subject: [PATCH 19/34] =?UTF-8?q?[FIX]=20exception=20=EC=8B=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=83=9D=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update: 스택트레이스 출력 * update: 로그 형식 변경 * fix: syncContentBuilder 필요없는 부분은 삭제 --- .../operation/service/OperationQueueProcessor.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 index 09df263..f0d5f72 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -56,10 +56,10 @@ public void startProcessing() { OperationRequestDto operation = operationQueue.take(); processOperation(operation); } catch (InterruptedException e) { - log.error("QUEUE PROCESSOR THREAD INTERRUPTED: {}", e.getMessage()); + log.error("QUEUE PROCESSOR THREAD INTERRUPTED: {}", e.getMessage(), e); break; } catch (Exception e) { - log.error("QUEUE PROCESSOR UNCAUGHT EXCEPTION: {}", e.getMessage()); + log.error("QUEUE PROCESSOR UNCAUGHT EXCEPTION: {}", e.getMessage(), e); } } }).start(); @@ -85,6 +85,8 @@ private void createTestDocument() { documentRepository.save(testDoc); // (임시) 테스트 문서 operation 로그 초기화 operationRepository.deleteByDocumentId(TEST_DOC_ID); + + log.info("CREATED TEST DOCUMENT: {}", testDoc); } catch (Exception e) { log.error("Exception while creating test document: {}", e.getMessage()); } @@ -97,6 +99,7 @@ public void fillDocumentPool() { for(Document doc : documents) { documentCache.put(doc.getId(), doc); } + log.info("FILLED DOCUMENT POOL : {}", documentCache); } /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ @@ -146,7 +149,7 @@ public void processOperation(OperationRequestDto operation) { try { // 로그 출력 ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); log.info("{} Received: {}", now.format(formatter), operation); List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); for (Operation concurrentOp : concurrentOperations) { @@ -173,7 +176,6 @@ public void processOperation(OperationRequestDto operation) { case DELETE -> doc.getContentBuilder().delete(idx, operation.getDeleteLength()); } dirtyDocuments.add(docId); - doc.syncContentBuilder(); // Operation DB에 저장 && Document version 업데이트 // - 동기 처리 vs 비동기 처리 @@ -196,7 +198,7 @@ public void processOperation(OperationRequestDto operation) { // 클라이언트에 브로드캐스트 template.convertAndSend("/sub/edit/" + docId, response); } catch (Exception e) { - log.error("Exception while handling operation {}: {}", operation, e.getMessage()); + log.error("Exception while handling operation {} -> {}", operation, e.getMessage(), e); } } @@ -209,7 +211,7 @@ public void processOperation(OperationRequestDto operation) { @Transactional public void saveDirtyDocuments() { // 로그 출력 - if(!dirtyDocuments.isEmpty()) System.out.println("SAVING DIRTY DOCUMENTS (id=" + dirtyDocuments + ")"); + if(!dirtyDocuments.isEmpty()) log.info("SAVING DIRTY DOCUMENTS (id=" + dirtyDocuments + ")"); for (Long docId : dirtyDocuments) { Document doc = documentCache.get(docId); if (doc != null) { From 4f8fc3128c9e41b720a95b6a37261ba4acfac470 Mon Sep 17 00:00:00 2001 From: JaehwanH Date: Wed, 12 Mar 2025 13:09:09 +0900 Subject: [PATCH 20/34] =?UTF-8?q?[UPDATE]=20=EB=A1=9C=EA=B9=85=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../operation/controller/WebsocketEventController.java | 4 +++- .../domain/operation/service/OperationQueueProcessor.java | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) 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 index 13e1c1a..82e2f81 100644 --- a/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java +++ b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java @@ -2,6 +2,7 @@ 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; @@ -9,6 +10,7 @@ import java.util.concurrent.BlockingQueue; +@Slf4j @Controller public class WebsocketEventController { private final BlockingQueue operationQueue; @@ -26,7 +28,7 @@ public WebsocketEventController( @MessageMapping("/edit") public void receiveEditOperation(@Valid OperationRequestDto operation) throws InterruptedException { operationQueue.put(operation); - System.out.println(operation + " put to queue"); + log.info(operation + " put to queue"); template.convertAndSend("/sub/ack/" + operation.getDocumentId(), "ACK"); } 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 index f0d5f72..58ca7b4 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -148,9 +148,7 @@ public void processOperation(OperationRequestDto operation) { // -> 클라이언트 ACK 추적 기능 구현 되면 (2)번으로 갈아타기 try { // 로그 출력 - ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); - log.info("{} Received: {}", now.format(formatter), operation); + log.info("Received: {}", operation); List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); for (Operation concurrentOp : concurrentOperations) { if (concurrentOp.getOperation().equals(OperationType.INSERT) && concurrentOp.getPosition() < opPosition) { @@ -175,6 +173,7 @@ public void processOperation(OperationRequestDto operation) { case INSERT -> doc.getContentBuilder().insert(idx, operation.getInsertContent()); case DELETE -> doc.getContentBuilder().delete(idx, operation.getDeleteLength()); } + log.info("current content: {}", doc.getContentBuilder().toString()); dirtyDocuments.add(docId); // Operation DB에 저장 && Document version 업데이트 @@ -211,11 +210,12 @@ public void processOperation(OperationRequestDto operation) { @Transactional public void saveDirtyDocuments() { // 로그 출력 - if(!dirtyDocuments.isEmpty()) log.info("SAVING DIRTY DOCUMENTS (id=" + dirtyDocuments + ")"); + log.info("SAVING DIRTY DOCUMENTS (id=" + dirtyDocuments + ")"); for (Long docId : dirtyDocuments) { Document doc = documentCache.get(docId); if (doc != null) { synchronized (doc) { + log.info(" - DOCUMENT {}: {}", docId, doc.getContentBuilder().toString()); doc.syncContentBuilder(); documentRepository.save(doc); } From 7a59fe699f5ee69b4bd63ca5b4af1da0b8073124 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Wed, 12 Mar 2025 13:19:20 +0900 Subject: [PATCH 21/34] =?UTF-8?q?[HOTFIX]=20DELETE=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=8B=9C=20StringIndexOutOfBounds=20=EC=9D=B5=EC=85=89?= =?UTF-8?q?=EC=85=98=20=ED=95=B4=EA=B2=B0=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/operation/service/OperationQueueProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 58ca7b4..94e28df 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -171,7 +171,7 @@ public void processOperation(OperationRequestDto operation) { int idx = Math.toIntExact(opPosition); switch(operation.getOperation()) { case INSERT -> doc.getContentBuilder().insert(idx, operation.getInsertContent()); - case DELETE -> doc.getContentBuilder().delete(idx, operation.getDeleteLength()); + case DELETE -> doc.getContentBuilder().delete(idx - operation.getDeleteLength(), idx); } log.info("current content: {}", doc.getContentBuilder().toString()); dirtyDocuments.add(docId); From f3e52e3cdadaac851d015c32726f0949893dfa80 Mon Sep 17 00:00:00 2001 From: JaehwanH Date: Wed, 12 Mar 2025 14:18:04 +0900 Subject: [PATCH 22/34] =?UTF-8?q?[HOTFIX]:=20Document=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=A0=84=20=EB=82=B4=EC=9A=A9=20=EB=A1=9C=EA=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gdg/backend/domain/document/entity/Document.java | 5 +++++ .../domain/operation/service/OperationQueueProcessor.java | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) 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 index b50147b..3bace26 100644 --- a/src/main/java/com/gdg/backend/domain/document/entity/Document.java +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -51,4 +51,9 @@ public void syncContentBeforeSave() { content = contentBuilder.toString(); } } + + @Override + public String toString() { + return String.format("DOCUMENT(id=%d, version=%d", id, version) + "content=" + content + ")"; + } } 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 index 94e28df..84141e7 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -215,8 +215,8 @@ public void saveDirtyDocuments() { Document doc = documentCache.get(docId); if (doc != null) { synchronized (doc) { - log.info(" - DOCUMENT {}: {}", docId, doc.getContentBuilder().toString()); doc.syncContentBuilder(); + log.info("- SAVING DOCUMENT: {}", doc); documentRepository.save(doc); } } From 1310bff1ba3864df2bd4be33823ca23cf4285360 Mon Sep 17 00:00:00 2001 From: JaehwanH Date: Wed, 12 Mar 2025 14:25:42 +0900 Subject: [PATCH 23/34] =?UTF-8?q?[HOTFIX]:=20Document=20@PreUpate=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EB=A1=9C=EA=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gdg/backend/domain/document/entity/Document.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 3bace26..3f83a05 100644 --- a/src/main/java/com/gdg/backend/domain/document/entity/Document.java +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -47,13 +47,15 @@ public void initContentBuilder() { @PrePersist @PreUpdate public void syncContentBeforeSave() { + System.out.println("SyncContentBeforeSave(): contentBuilder=" + contentBuilder + " content=" + content); if (contentBuilder != null) { content = contentBuilder.toString(); + System.out.println("SyncContentBeforeSave(): contentBuilder=" + contentBuilder.toString() + " content=" + content); } } @Override public String toString() { - return String.format("DOCUMENT(id=%d, version=%d", id, version) + "content=" + content + ")"; + return String.format("DOCUMENT(id=%d, version=%d, ", id, version) + "content=" + content + ")"; } } From 39bb70f130a28c06b61b104738b7afa26bcfa3ff Mon Sep 17 00:00:00 2001 From: JaehwanH Date: Wed, 12 Mar 2025 14:34:09 +0900 Subject: [PATCH 24/34] =?UTF-8?q?[HOTFIX]:=20DELETE=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8B=B1=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/operation/service/OperationQueueProcessor.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 84141e7..7329841 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -171,7 +171,7 @@ public void processOperation(OperationRequestDto operation) { int idx = Math.toIntExact(opPosition); switch(operation.getOperation()) { case INSERT -> doc.getContentBuilder().insert(idx, operation.getInsertContent()); - case DELETE -> doc.getContentBuilder().delete(idx - operation.getDeleteLength(), idx); + case DELETE -> doc.getContentBuilder().delete(idx - operation.getDeleteLength() + 1, idx + 1); } log.info("current content: {}", doc.getContentBuilder().toString()); dirtyDocuments.add(docId); @@ -210,7 +210,7 @@ public void processOperation(OperationRequestDto operation) { @Transactional public void saveDirtyDocuments() { // 로그 출력 - log.info("SAVING DIRTY DOCUMENTS (id=" + dirtyDocuments + ")"); + if(!dirtyDocuments.isEmpty()) log.info("SAVING DIRTY DOCUMENTS (id=" + dirtyDocuments + ")"); for (Long docId : dirtyDocuments) { Document doc = documentCache.get(docId); if (doc != null) { From 187ce309f582d40b26033c20cb6c353e65de3f9c Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Wed, 12 Mar 2025 20:26:19 +0900 Subject: [PATCH 25/34] =?UTF-8?q?[FIX]=20Document=20content=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/document/entity/Document.java | 17 +++------- .../controller/WebsocketEventController.java | 3 +- .../service/OperationQueueProcessor.java | 31 ++++++++++++------- 3 files changed, 24 insertions(+), 27 deletions(-) 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 index 3f83a05..3cb52bb 100644 --- a/src/main/java/com/gdg/backend/domain/document/entity/Document.java +++ b/src/main/java/com/gdg/backend/domain/document/entity/Document.java @@ -40,22 +40,13 @@ public void syncContentBuilder() { // DB에서 로드 시 content -> contentBuilder 초기화 @PostLoad public void initContentBuilder() { - this.contentBuilder = new StringBuilder(content != null ? content : ""); - } - - // INSERT, UPDATE 전 contentBuilder -> content 동기화 - @PrePersist - @PreUpdate - public void syncContentBeforeSave() { - System.out.println("SyncContentBeforeSave(): contentBuilder=" + contentBuilder + " content=" + content); - if (contentBuilder != null) { - content = contentBuilder.toString(); - System.out.println("SyncContentBeforeSave(): contentBuilder=" + contentBuilder.toString() + " content=" + content); - } + 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, ", id, version) + "content=" + content + ")"; + 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/operation/controller/WebsocketEventController.java b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java index 82e2f81..c96792a 100644 --- a/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java +++ b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java @@ -6,7 +6,6 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.CrossOrigin; import java.util.concurrent.BlockingQueue; @@ -26,7 +25,7 @@ public WebsocketEventController( /** 클라이언트의 문서 편집 요청을 메시지 큐에 push */ @MessageMapping("/edit") - public void receiveEditOperation(@Valid OperationRequestDto operation) throws InterruptedException { + public void handleEditOperation(@Valid OperationRequestDto operation) throws InterruptedException { operationQueue.put(operation); log.info(operation + " put to queue"); template.convertAndSend("/sub/ack/" + operation.getDocumentId(), "ACK"); 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 index 7329841..6d03294 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -169,9 +169,12 @@ public void processOperation(OperationRequestDto operation) { // 문서 상태 갱신 int idx = Math.toIntExact(opPosition); - switch(operation.getOperation()) { - case INSERT -> doc.getContentBuilder().insert(idx, operation.getInsertContent()); - case DELETE -> doc.getContentBuilder().delete(idx - operation.getDeleteLength() + 1, idx + 1); + 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()); } log.info("current content: {}", doc.getContentBuilder().toString()); dirtyDocuments.add(docId); @@ -203,21 +206,25 @@ public void processOperation(OperationRequestDto operation) { /** 주기적으로 변경된 문서 저장
* - DB 쓰기는 네트워크 요청 + 디스크 I/O를 포함하므로 무거움 - * - processOperation에서 제거해서 실행시간 줄여서 사용자 경험 증가 & 트랜잭션 부하 감소 + * - 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() { - // 로그 출력 - if(!dirtyDocuments.isEmpty()) log.info("SAVING DIRTY DOCUMENTS (id=" + dirtyDocuments + ")"); for (Long docId : dirtyDocuments) { - Document doc = documentCache.get(docId); - if (doc != null) { - synchronized (doc) { - doc.syncContentBuilder(); - log.info("- SAVING DOCUMENT: {}", doc); - documentRepository.save(doc); + Document cachedDoc = documentCache.get(docId); + if (cachedDoc != null) { + synchronized (cachedDoc) { + cachedDoc.syncContentBuilder(); + documentRepository.save(cachedDoc); } } } From f9bd1d4f34afe35978aee065d96801117bffb2cd Mon Sep 17 00:00:00 2001 From: YamYamee <82762402+YamYamee@users.noreply.github.com> Date: Thu, 13 Mar 2025 14:49:23 +0900 Subject: [PATCH 26/34] =?UTF-8?q?[FEAT]=20=EB=B9=8C=EB=93=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B0=95=EC=9D=98=EC=8B=A4=20api=20=EA=B5=AC=ED=98=84=20(#3?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add: 일부 구현 * add: 빌드 저장 기능 구현 * fix: 로직 수정 --- .../gdg/backend/config/WebsocketConfig.java | 7 +- .../domain/assignment/entity/Assignment.java | 23 +++ .../repository/AssignmentRepository.java | 7 + .../domain/attendence/entity/Attendance.java | 1 - .../backend/domain/build/BuildRepository.java | 13 ++ .../build/controller/BuildController.java | 28 +++ .../domain/build/dto/BuildRequest.java | 15 ++ .../domain/build/dto/BuildResponse.java | 13 ++ .../domain/build/dto/InputMessage.java | 13 ++ .../backend/domain/build/entity/Build.java | 7 + .../build/service/CodeExecutionService.java | 185 ++++++++++++++++++ .../controller/ClassroomController.java | 8 +- .../domain/classroom/dto/ClassroomDto.java | 60 ++++++ .../classroom/service/ClassroomService.java | 3 + .../service/ClassroomServiceImpl.java | 117 +++++++++++ .../repository/DocumentRepository.java | 3 +- .../backend/domain/enums/LanguageType.java | 16 ++ .../repository/InvitationRepository.java | 5 + .../gdg/backend/domain/mapping/IdeMember.java | 36 ++++ .../repository/IdeMemberRepository.java | 14 ++ .../backend/domain/member/dto/CourseInfo.java | 2 + .../backend/domain/member/entity/Member.java | 1 + .../member/service/MemberServiceImpl.java | 4 +- .../backend/domain/notice/entity/Notice.java | 3 +- .../notice/repository/NoticeRepository.java | 7 + 25 files changed, 582 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/gdg/backend/domain/assignment/entity/Assignment.java create mode 100644 src/main/java/com/gdg/backend/domain/assignment/repository/AssignmentRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/build/BuildRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/build/controller/BuildController.java create mode 100644 src/main/java/com/gdg/backend/domain/build/dto/BuildRequest.java create mode 100644 src/main/java/com/gdg/backend/domain/build/dto/BuildResponse.java create mode 100644 src/main/java/com/gdg/backend/domain/build/dto/InputMessage.java create mode 100644 src/main/java/com/gdg/backend/domain/build/service/CodeExecutionService.java create mode 100644 src/main/java/com/gdg/backend/domain/classroom/dto/ClassroomDto.java create mode 100644 src/main/java/com/gdg/backend/domain/enums/LanguageType.java create mode 100644 src/main/java/com/gdg/backend/domain/mapping/IdeMember.java create mode 100644 src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java create mode 100644 src/main/java/com/gdg/backend/domain/notice/repository/NoticeRepository.java diff --git a/src/main/java/com/gdg/backend/config/WebsocketConfig.java b/src/main/java/com/gdg/backend/config/WebsocketConfig.java index a01ef7f..824e8e6 100644 --- a/src/main/java/com/gdg/backend/config/WebsocketConfig.java +++ b/src/main/java/com/gdg/backend/config/WebsocketConfig.java @@ -1,14 +1,15 @@ 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.EnableWebSocketMessageBroker; -import org.springframework.web.socket.config.annotation.StompEndpointRegistry; -import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +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/**' 경로로 브로드캐스트 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 index 070d188..e8a84fd 100644 --- a/src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java +++ b/src/main/java/com/gdg/backend/domain/attendence/entity/Attendance.java @@ -22,5 +22,4 @@ public class Attendance extends BaseTimeEntity { @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 index 88053ad..5da9355 100644 --- a/src/main/java/com/gdg/backend/domain/build/entity/Build.java +++ b/src/main/java/com/gdg/backend/domain/build/entity/Build.java @@ -1,13 +1,16 @@ 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 @@ -22,6 +25,10 @@ public class Build extends BaseTimeEntity { @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/classroom/controller/ClassroomController.java b/src/main/java/com/gdg/backend/domain/classroom/controller/ClassroomController.java index 3078d7b..0d1743a 100644 --- a/src/main/java/com/gdg/backend/domain/classroom/controller/ClassroomController.java +++ b/src/main/java/com/gdg/backend/domain/classroom/controller/ClassroomController.java @@ -2,7 +2,7 @@ import com.gdg.backend.common.annotation.AuthUser; import com.gdg.backend.common.response.ApiResponse; -import com.gdg.backend.domain.classroom.entity.Classroom; +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; @@ -32,4 +32,10 @@ public ApiResponse enterClassroom(@RequestParam String code, @AuthUser M 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/service/ClassroomService.java b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomService.java index 24cba6f..d16b3d8 100644 --- a/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomService.java +++ b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomService.java @@ -1,5 +1,6 @@ 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; @@ -8,4 +9,6 @@ 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 index eb94e96..c0a38ca 100644 --- a/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomServiceImpl.java +++ b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomServiceImpl.java @@ -2,25 +2,49 @@ 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 @@ -36,6 +60,16 @@ public String createClassroom(String name, Member member) { 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; } @@ -45,6 +79,11 @@ 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); @@ -52,7 +91,85 @@ public String enterClassroom(String code, Member member) { 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(); + IdeMember ideMember = ideMemberRepository.findByMember(member).orElseThrow(()-> new GeneralHandler(ErrorCode.MEMBER_NOT_FOUND)); + Classroom classroom = ideMember.getClassroom(); + + // 선생님의 ide 가져오기 + IdeMember ideTeacher = ideMemberRepository.findByMember(classroom.getTeacher()).orElseThrow(()-> new GeneralHandler(ErrorCode.MEMBER_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/document/repository/DocumentRepository.java b/src/main/java/com/gdg/backend/domain/document/repository/DocumentRepository.java index 7c27ba2..5fbb725 100644 --- a/src/main/java/com/gdg/backend/domain/document/repository/DocumentRepository.java +++ b/src/main/java/com/gdg/backend/domain/document/repository/DocumentRepository.java @@ -6,8 +6,9 @@ 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/invitation/repository/InvitationRepository.java b/src/main/java/com/gdg/backend/domain/invitation/repository/InvitationRepository.java index 47e3a3f..b5b69f4 100644 --- a/src/main/java/com/gdg/backend/domain/invitation/repository/InvitationRepository.java +++ b/src/main/java/com/gdg/backend/domain/invitation/repository/InvitationRepository.java @@ -1,5 +1,6 @@ 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; @@ -10,4 +11,8 @@ 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/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..4190716 --- /dev/null +++ b/src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java @@ -0,0 +1,14 @@ +package com.gdg.backend.domain.mapping.repository; + +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 java.util.Optional; + +public interface IdeMemberRepository extends JpaRepository { + Optional findByMember(Member member); + Optional findByDocument(Document document); +} \ No newline at end of file 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 index 66f3fa6..4946ed4 100644 --- a/src/main/java/com/gdg/backend/domain/member/dto/CourseInfo.java +++ b/src/main/java/com/gdg/backend/domain/member/dto/CourseInfo.java @@ -12,6 +12,7 @@ public class CourseInfo { public static class TeacherCourseInfo { private String courseCode; private String courseName; + private Long courseId; } @Getter @@ -20,5 +21,6 @@ public static class TeacherCourseInfo { public static class StudentCourseInfo { private String teacherName; private String courseName; + private Long courseId; } } 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 index af7a3b3..e3aa469 100644 --- a/src/main/java/com/gdg/backend/domain/member/entity/Member.java +++ b/src/main/java/com/gdg/backend/domain/member/entity/Member.java @@ -13,6 +13,7 @@ @NoArgsConstructor @AllArgsConstructor public abstract class Member { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; 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 index 26b8eac..4dcaed0 100644 --- a/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java @@ -118,7 +118,7 @@ public Object getDashboardInfo(Member member) { List studentCourseInfos = new java.util.ArrayList<>(List.of()); invitations.forEach(invitation -> { - studentCourseInfos.add(new CourseInfo.StudentCourseInfo(invitation.getClassroom().getTeacher().getUsername(), invitation.getClassroom().getName())); + studentCourseInfos.add(new CourseInfo.StudentCourseInfo(invitation.getClassroom().getName(), invitation.getClassroom().getTeacher().getUsername(), invitation.getClassroom().getId())); }); return new DashBoardInfoDto.StudentDashBoardInfoDto(member.getUsername(), studentCourseInfos); @@ -130,7 +130,7 @@ public Object getDashboardInfo(Member member) { List teacherCourseInfos = new java.util.ArrayList<>(List.of()); classrooms.forEach(classroom -> { - teacherCourseInfos.add(new CourseInfo.TeacherCourseInfo(classroom.getInvitationCode(), classroom.getName())); + teacherCourseInfos.add(new CourseInfo.TeacherCourseInfo(classroom.getInvitationCode(), classroom.getName(), classroom.getId())); }); return new DashBoardInfoDto.TeacherDashBoardInfoDto(member.getUsername(), teacherCourseInfos); 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 index 0559dcd..809df14 100644 --- a/src/main/java/com/gdg/backend/domain/notice/entity/Notice.java +++ b/src/main/java/com/gdg/backend/domain/notice/entity/Notice.java @@ -1,12 +1,13 @@ 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 { +public class Notice extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; 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 { +} From 4dc961a7c641abcea4522f1d7f1be17ee6179b0b Mon Sep 17 00:00:00 2001 From: YamYamee <82762402+YamYamee@users.noreply.github.com> Date: Thu, 13 Mar 2025 17:41:10 +0900 Subject: [PATCH 27/34] =?UTF-8?q?[HOTFIX]=20=EC=88=9C=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gdg/backend/domain/member/service/MemberServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4dcaed0..d91315f 100644 --- a/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/gdg/backend/domain/member/service/MemberServiceImpl.java @@ -118,7 +118,7 @@ public Object getDashboardInfo(Member member) { List studentCourseInfos = new java.util.ArrayList<>(List.of()); invitations.forEach(invitation -> { - studentCourseInfos.add(new CourseInfo.StudentCourseInfo(invitation.getClassroom().getName(), invitation.getClassroom().getTeacher().getUsername(), invitation.getClassroom().getId())); + studentCourseInfos.add(new CourseInfo.StudentCourseInfo(invitation.getClassroom().getTeacher().getUsername(), invitation.getClassroom().getName(), invitation.getClassroom().getId())); }); return new DashBoardInfoDto.StudentDashBoardInfoDto(member.getUsername(), studentCourseInfos); From c0fcf96432c7b50d7529dc56237ab3e299ea9081 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Thu, 13 Mar 2025 22:27:25 +0900 Subject: [PATCH 28/34] =?UTF-8?q?[HOTFIX]=20sync=20opPosition=20null?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=AF=B8=EB=A3=B8=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gdg/backend/domain/operation/dto/OperationRequestDto.java | 2 +- .../domain/operation/service/OperationQueueProcessor.java | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) 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 index 1d85a7b..c37f9e1 100644 --- a/src/main/java/com/gdg/backend/domain/operation/dto/OperationRequestDto.java +++ b/src/main/java/com/gdg/backend/domain/operation/dto/OperationRequestDto.java @@ -21,7 +21,7 @@ public class OperationRequestDto { Integer deleteLength; - @NotNull(message = "position은 null일 수 없습니다.") +// @NotNull(message = "position은 null일 수 없습니다.") Long position; Long baseVersion; 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 index 6d03294..263aba1 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -134,6 +134,9 @@ public void processOperation(OperationRequestDto operation) { template.convertAndSend("/sub/edit/" + docId, docContent); return; } + + // 추후 수정 + if(opPosition == null) throw new IllegalStateException("opPosition은 null일 수 없습니다."); // operation 충돌 시 변환 처리 // - operation의 baseVersion과 서버가 추적하는 version을 비교 From db61d5d852e4fadf75077017ff8ada9f45b880d4 Mon Sep 17 00:00:00 2001 From: JaehwanH Date: Fri, 14 Mar 2025 00:11:33 +0900 Subject: [PATCH 29/34] =?UTF-8?q?[HOTFIX]=20sync=20response=20dto=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../operation/dto/SyncOperationResponseDto.java | 16 ++++++++++++++++ .../service/OperationQueueProcessor.java | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gdg/backend/domain/operation/dto/SyncOperationResponseDto.java 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/service/OperationQueueProcessor.java b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java index 263aba1..5a2c820 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -8,6 +8,7 @@ import com.gdg.backend.domain.enums.OperationType; 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; @@ -131,7 +132,8 @@ public void processOperation(OperationRequestDto operation) { if(operation.getOperation().equals(OperationType.SYNC)) { log.info("Received: SYNC"); String docContent = doc.getContentBuilder().toString(); - template.convertAndSend("/sub/edit/" + docId, docContent); + SyncOperationResponseDto response = new SyncOperationResponseDto(OperationType.SYNC, operation.getUserId(), documentVersions.get(docId).get(), docContent); + template.convertAndSend("/sub/edit/" + docId, response); return; } From 738a4cdcf39b68ba5dbe0294826b273a34966e24 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Fri, 14 Mar 2025 00:16:48 +0900 Subject: [PATCH 30/34] =?UTF-8?q?[FEAT]=20=ED=95=99=EC=83=9D=20=EB=8F=84?= =?UTF-8?q?=EC=9B=80=20=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 도움 요청 기능 * typo: log 출력 오타 수정 * remove: StackTraceLimiter 삭제 logback에 내장된 기능이므로 삭제 * update: 도움 요청 수신 시 ACK --- .../controller/HelpRequestController.java | 29 +++++++++++++++++ .../helprequest/dto/HelpRequestDto.java | 12 +++++++ .../dto/HelpRequestResponseDto.java | 18 +++++++++++ .../service/HelpRequestService.java | 32 +++++++++++++++++++ .../repository/IdeMemberRepository.java | 4 +++ .../controller/WebsocketEventController.java | 2 -- 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/gdg/backend/domain/helprequest/controller/HelpRequestController.java create mode 100644 src/main/java/com/gdg/backend/domain/helprequest/dto/HelpRequestDto.java create mode 100644 src/main/java/com/gdg/backend/domain/helprequest/dto/HelpRequestResponseDto.java create mode 100644 src/main/java/com/gdg/backend/domain/helprequest/service/HelpRequestService.java 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/mapping/repository/IdeMemberRepository.java b/src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java index 4190716..461c045 100644 --- a/src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java +++ b/src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java @@ -4,6 +4,7 @@ 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; @@ -11,4 +12,7 @@ public interface IdeMemberRepository extends JpaRepository { Optional findByMember(Member member); 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/operation/controller/WebsocketEventController.java b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java index c96792a..41da692 100644 --- a/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java +++ b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java @@ -30,6 +30,4 @@ public void handleEditOperation(@Valid OperationRequestDto operation) throws Int log.info(operation + " put to queue"); template.convertAndSend("/sub/ack/" + operation.getDocumentId(), "ACK"); } - - } From 3b78b1238b1280d20fd5a3bae0fdf88f5c61a071 Mon Sep 17 00:00:00 2001 From: YamYamee <82762402+YamYamee@users.noreply.github.com> Date: Fri, 14 Mar 2025 21:39:10 +0900 Subject: [PATCH 31/34] =?UTF-8?q?[FIX]=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/common/response/status/ErrorCode.java | 4 ++++ .../classroom/service/ClassroomServiceImpl.java | 12 ++++++++---- .../mapping/repository/IdeMemberRepository.java | 5 +++++ 3 files changed, 17 insertions(+), 4 deletions(-) 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 index e715df2..d47e7f8 100644 --- a/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java +++ b/src/main/java/com/gdg/backend/common/response/status/ErrorCode.java @@ -33,6 +33,10 @@ public enum ErrorCode implements BaseErrorCode { // 문서 관련 에러 DOCUMENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "DOCUMENT4001", "문서를 찾을 수 없습니다."), + + // 강의실 관련 + CLASSROOM_NOT_FOUND(HttpStatus.BAD_REQUEST, "CLASSROOM4001", "강의실을 찾을 수 없습니다.") + ; 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 index c0a38ca..5f2f607 100644 --- a/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomServiceImpl.java +++ b/src/main/java/com/gdg/backend/domain/classroom/service/ClassroomServiceImpl.java @@ -109,13 +109,17 @@ public ClassroomDto.ClassroomResponseDto getClassroomInfo(Long classroomId, Memb ClassroomDto.ClassroomResponseDto classroomResponseDto = new ClassroomDto.ClassroomResponseDto(); - IdeMember ideMember = ideMemberRepository.findByMember(member).orElseThrow(()-> new GeneralHandler(ErrorCode.MEMBER_NOT_FOUND)); - Classroom classroom = ideMember.getClassroom(); + + // 강의실 정보 찾기 + 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.findByMember(classroom.getTeacher()).orElseThrow(()-> new GeneralHandler(ErrorCode.MEMBER_NOT_FOUND)); - classroomResponseDto.setTeacherIdeId(ideTeacher.getDocument().getId()); + IdeMember ideTeacher = ideMemberRepository.findByMemberAndClassroom(classroom.getTeacher(), classroom).orElseThrow(()-> new GeneralHandler(ErrorCode.CLASSROOM_NOT_FOUND)); + classroomResponseDto.setTeacherIdeId(ideTeacher.getDocument().getId()); // 학생의 ide 가져오기 if(member instanceof Student) { 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 index 461c045..6377613 100644 --- a/src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java +++ b/src/main/java/com/gdg/backend/domain/mapping/repository/IdeMemberRepository.java @@ -1,5 +1,6 @@ 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; @@ -10,7 +11,11 @@ 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") From db9f06548ac2bc38dccdc91a85bc8eb35402dec4 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Fri, 14 Mar 2025 23:24:19 +0900 Subject: [PATCH 32/34] =?UTF-8?q?[FIX]=20documentId=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=EA=B2=BD=EC=9A=B0=20documentVersion=EB=8F=84=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/OperationQueueProcessor.java | 41 +++++-------------- 1 file changed, 10 insertions(+), 31 deletions(-) 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 index 5a2c820..63cb021 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -68,50 +68,25 @@ public void startProcessing() { @PostConstruct public void postConstructJob() { - createTestDocument(); fillDocumentPool(); fillDocumentVersionPool(); } - /** (임시) 테스트 문서 초기화 -> 다른 PostConstruct 메소드보다 먼저 호출되어야 함 */ - private void createTestDocument() { - try { - final Long TEST_DOC_ID = 1L; - Document testDoc = documentRepository.findById(TEST_DOC_ID) - .orElse(Document.builder() - .id(1L) - .build()); - testDoc.setVersion(0L); - testDoc.setContent(""); - documentRepository.save(testDoc); - // (임시) 테스트 문서 operation 로그 초기화 - operationRepository.deleteByDocumentId(TEST_DOC_ID); - - log.info("CREATED TEST DOCUMENT: {}", testDoc); - } catch (Exception e) { - log.error("Exception while creating test document: {}", e.getMessage()); - } - } - /** DB에서 Document fetch해서 메모리로 가져옴 */ -// @PostConstruct public void fillDocumentPool() { List documents = documentRepository.findAll(); - for(Document doc : documents) { - documentCache.put(doc.getId(), doc); - } + documents.forEach(doc -> documentCache.put(doc.getId(), doc)); log.info("FILLED DOCUMENT POOL : {}", documentCache); } /** DB에 존재하는 Document version pool 추적 (인메모리라서 서버 껐다키면 사라지니까..) */ -// @PostConstruct 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 POOL: {}", documentVersions); + log.info("FILLED DOCUMENT VERSION POOL: {}", documentVersions); } @TrackExecutionTime @@ -120,10 +95,14 @@ public void processOperation(OperationRequestDto operation) { Long baseVersion = operation.getBaseVersion(); Long opPosition = operation.getPosition(); - // documentID 없는 경우 예외처리 (캐시엔 없지만 DB에 있는 경우 캐시 업데이트) - Document doc = documentCache.computeIfAbsent(docId, id -> documentRepository.findById(docId) - .orElseThrow(() -> new GeneralHandler(ErrorCode.DOCUMENT_NOT_FOUND)) - ); + // 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인 경우 따로 처리 // - 현재 문서 상태 브로드캐스팅 From a20a53e5b80c85d79a974855592bdc187cdf30e4 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Sat, 15 Mar 2025 01:29:30 +0900 Subject: [PATCH 33/34] =?UTF-8?q?[UPDATE]=20Operation=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 충돌 처리 시 본인 operation 적용하지 않음 * fix: MemberRepository에서 멤버 검색해서 저장 * fix: concurrentOp 찾는 과정에서 N+1 해결 * update: 충돌 처리 작업 변환 로직 분기 * update: deleteLength > 1인 경우도 고려함 * fix: delete 범위 겹치는 경우 그냥 return하도록 변경 * fix: delete 범위 삭제의 경우 분기 처리 --- .../controller/WebsocketEventController.java | 2 +- .../domain/operation/entity/Operation.java | 4 +- .../repository/OperationRepository.java | 5 +- .../service/OperationQueueProcessor.java | 72 ++++++++++++++----- 4 files changed, 61 insertions(+), 22 deletions(-) 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 index 41da692..5eae34c 100644 --- a/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java +++ b/src/main/java/com/gdg/backend/domain/operation/controller/WebsocketEventController.java @@ -27,7 +27,7 @@ public WebsocketEventController( @MessageMapping("/edit") public void handleEditOperation(@Valid OperationRequestDto operation) throws InterruptedException { operationQueue.put(operation); - log.info(operation + " put to queue"); + log.info("[ENQUEUE] {}", operation); template.convertAndSend("/sub/ack/" + operation.getDocumentId(), "ACK"); } } 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 index ac884a0..5e4c8ad 100644 --- a/src/main/java/com/gdg/backend/domain/operation/entity/Operation.java +++ b/src/main/java/com/gdg/backend/domain/operation/entity/Operation.java @@ -26,11 +26,11 @@ public class Operation extends BaseTimeEntity { @Enumerated(EnumType.STRING) private OperationType operation; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "document_id") private Document document; 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 index 01851af..ef34a63 100644 --- a/src/main/java/com/gdg/backend/domain/operation/repository/OperationRepository.java +++ b/src/main/java/com/gdg/backend/domain/operation/repository/OperationRepository.java @@ -2,6 +2,8 @@ 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; @@ -9,7 +11,8 @@ @Repository public interface OperationRepository extends JpaRepository { - List findByDocumentIdAndVersionGreaterThan(Long documentId, Long version); + @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 index 63cb021..9929d57 100644 --- a/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java +++ b/src/main/java/com/gdg/backend/domain/operation/service/OperationQueueProcessor.java @@ -6,6 +6,8 @@ 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; @@ -19,11 +21,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; 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; @@ -38,6 +38,7 @@ 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<>(); @@ -109,7 +110,7 @@ public void processOperation(OperationRequestDto operation) { // - version을 높이지 않음 // - 추후 전략 패턴 등으로 추상화 if(operation.getOperation().equals(OperationType.SYNC)) { - log.info("Received: 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); @@ -132,17 +133,44 @@ public void processOperation(OperationRequestDto operation) { // -> 클라이언트 ACK 추적 기능 구현 되면 (2)번으로 갈아타기 try { // 로그 출력 - log.info("Received: {}", operation); - List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThan(docId, baseVersion); + log.info("[OPERATION]: {}", operation); + List concurrentOperations = operationRepository.findByDocumentIdAndVersionGreaterThanFetchJoin(docId, baseVersion); for (Operation concurrentOp : concurrentOperations) { - if (concurrentOp.getOperation().equals(OperationType.INSERT) && concurrentOp.getPosition() < opPosition) { - // 현재 operation보다 앞에 삽입한 경우 - if(concurrentOp.getInsertContent() == null) continue;; + // 본인의 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) && concurrentOp.getPosition() < opPosition) { - // 현재 operation보다 앞을 삭제한 경우 - if(concurrentOp.getDeleteLength() == null) continue;; - opPosition -= concurrentOp.getDeleteLength(); + } + 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(); } } @@ -160,13 +188,24 @@ public void processOperation(OperationRequestDto operation) { } doc.setVersion(documentVersions.get(operation.getDocumentId()).get()); } - log.info("current content: {}", doc.getContentBuilder().toString()); 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) @@ -174,13 +213,10 @@ public void processOperation(OperationRequestDto operation) { .insertContent(response.getInsertContent()) .deleteLength(response.getDeleteLength()) .version(response.getVersion()) - .member(null) // todo + .member(author) // todo .build() ); - // 로그 출력 - log.info(" 수정된 Operation: {}", response); - // 클라이언트에 브로드캐스트 template.convertAndSend("/sub/edit/" + docId, response); } catch (Exception e) { From 790b2bfe1ee3351432c2784bf9c892008801ecd5 Mon Sep 17 00:00:00 2001 From: JaehwanH <54016683+ja7811@users.noreply.github.com> Date: Tue, 18 Mar 2025 00:55:02 +0900 Subject: [PATCH 34/34] =?UTF-8?q?doc:=20README.md=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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) + +