diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..5c27dce --- /dev/null +++ b/.env.template @@ -0,0 +1,27 @@ +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=bugi +DB_USERNAME=postgres +DB_PASSWORD=postgres +DB_SCHEMA=public + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_SSL_ENABLED=false + +# Frontend Configuration +ALLOWED_ORIGINS=http://localhost:3000 + +# Mail Configuration +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_USERNAME=your-email@gmail.com +MAIL_PASSWORD=your-app-password + +# JWT Configuration +JWT_SECRET=your-secret-key-min-256-bits-change-this-in-production +JWT_ACCESS_TOKEN_VALIDITY=1h +JWT_REFRESH_TOKEN_VALIDITY=30d diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..918243b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,59 @@ +name: Deploy + +on: + push: + branches: main + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +jobs: + image-build: + name: Image Build and Push to NCP Registry + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to NCP Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.CONTAINER_REGISTRY }} + username: ${{ secrets.NCP_ACCESS_KEY }} + password: ${{ secrets.NCP_SECRET_KEY }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + push: true + tags: ${{ secrets.CONTAINER_REGISTRY }}/server:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Deploy to Server + runs-on: ubuntu-latest + needs: image-build + steps: + - name: Execute + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT }} + script: | + echo ${{ secrets.NCP_SECRET_KEY }} | docker login --username ${{ secrets.NCP_ACCESS_KEY }} --password-stdin ${{ secrets.CONTAINER_REGISTRY }} + + if [ $(docker ps -q -f name=server) ]; then + docker stop server + docker rm server + fi + + docker pull ${{ secrets.CONTAINER_REGISTRY }}/server:${{ github.sha }} + docker run --name server --restart unless-stopped --env-file ${{ secrets.ENV_FILE }} --network bugi -p 8080:8080 -d ${{ secrets.CONTAINER_REGISTRY }}/server:${{ github.sha }} + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b0aaec --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +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/ + +### Kotlin ### +.kotlin + +### Environment ### +.env* +!.env.template \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1302151 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +######################## +# 1) Build stage +######################## +FROM gradle:8.14.3-jdk21-alpine AS builder + +WORKDIR /application + +COPY build.gradle.kts ./ +RUN gradle dependencies --no-daemon --quiet + +COPY src src +RUN gradle bootJar -x test --no-daemon --quiet \ + && java -Djarmode=layertools -jar build/libs/*.jar extract + +######################## +# 2) Runtime stage +######################## +FROM gcr.io/distroless/java21-debian12:nonroot AS runtime + +USER nonroot +WORKDIR /application + +COPY --from=builder --chown=nonroot:nonroot /application/dependencies/ ./ +COPY --from=builder --chown=nonroot:nonroot /application/snapshot-dependencies/ ./ +COPY --from=builder --chown=nonroot:nonroot /application/spring-boot-loader/ ./ +COPY --from=builder --chown=nonroot:nonroot /application/application/ ./ + +EXPOSE 8080 + +ENTRYPOINT ["java", "-XX:+UseG1GC", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=production", "org.springframework.boot.loader.launch.JarLauncher"] diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..5aef0e6 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + kotlin("jvm") version "1.9.25" + kotlin("plugin.jpa") version "1.9.25" + kotlin("plugin.spring") version "1.9.25" + id("org.springframework.boot") version "3.4.11" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "com.github.kusitms_bugi" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +springBoot { + buildInfo() +} + +repositories { + mavenCentral() +} + +dependencies { + kotlin() + web() + database() + security() + documentation() + other() + testing() +} + +fun DependencyHandlerScope.kotlin() { + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") +} + +fun DependencyHandlerScope.web() { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + implementation("org.springframework.boot:spring-boot-starter-validation") +} + +fun DependencyHandlerScope.database() { + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.postgresql:postgresql") +} + +fun DependencyHandlerScope.security() { + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") +} + +fun DependencyHandlerScope.documentation() { + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") +} + +fun DependencyHandlerScope.other() { + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-mail") +} + +fun DependencyHandlerScope.testing() { + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/kotlin/com/github/kusitms_bugi/Application.kt b/src/main/kotlin/com/github/kusitms_bugi/Application.kt new file mode 100644 index 0000000..f12fca2 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/Application.kt @@ -0,0 +1,11 @@ +package com.github.kusitms_bugi + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class Application + +fun main(args: Array) { + runApplication(*args) +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/application/AuthService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/application/AuthService.kt new file mode 100644 index 0000000..fb4face --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/application/AuthService.kt @@ -0,0 +1,103 @@ +package com.github.kusitms_bugi.domain.auth.application + +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.CheckEmailDuplicateRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.LoginRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.RefreshTokenRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.ResendVerificationEmailRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.SignupRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.VerifyEmailRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.toEntity +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.CheckEmailDuplicateResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.LoginResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.RefreshTokenResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.SignupResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.toResponse +import com.github.kusitms_bugi.domain.user.domain.UserRepository +import com.github.kusitms_bugi.global.exception.ApiException +import com.github.kusitms_bugi.global.exception.AuthExceptionCode +import com.github.kusitms_bugi.global.exception.UserExceptionCode +import com.github.kusitms_bugi.global.mail.EmailService +import com.github.kusitms_bugi.global.security.EmailVerificationTokenProvider +import com.github.kusitms_bugi.global.security.JwtTokenProvider +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AuthService( + private val emailService: EmailService, + private val userRepository: UserRepository, + private val passwordEncoder: PasswordEncoder, + private val jwtTokenProvider: JwtTokenProvider, + private val emailVerificationTokenProvider: EmailVerificationTokenProvider +) { + + @Transactional(readOnly = true) + fun checkEmailDuplicate(request: CheckEmailDuplicateRequest) = + CheckEmailDuplicateResponse( + isDuplicate = userRepository.findByEmail(request.email) != null + ) + + @Transactional + fun signup(request: SignupRequest): SignupResponse { + userRepository.findByEmail(request.email)?.let { + throw ApiException(UserExceptionCode.EMAIL_ALREADY_EXISTS) + } + + return request + .toEntity(password = passwordEncoder.encode(request.password)) + .let { userRepository.save(it) } + .also { user -> + emailVerificationTokenProvider.generateEmailVerificationToken(user.id) + .let { token -> emailService.sendVerificationEmail(user.email, token, request.callbackUrl) } + } + .toResponse() + } + + @Transactional + fun verifyEmail(request: VerifyEmailRequest) { + request.token + .takeIf { emailVerificationTokenProvider.validateToken(it) } + ?: throw ApiException(AuthExceptionCode.INVALID_TOKEN) + + emailVerificationTokenProvider.getUserIdFromToken(request.token) + .let { userRepository.findById(it) ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) } + .apply { active = true } + } + + @Transactional + fun resendVerificationEmail(request: ResendVerificationEmailRequest) = + (userRepository.findByEmail(request.email) ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND)) + .also { if (it.active) throw ApiException(UserExceptionCode.USER_ALREADY_ACTIVE) } + .let { user -> + emailVerificationTokenProvider.generateEmailVerificationToken(user.id) + .let { token -> emailService.sendVerificationEmail(user.email, token, request.callbackUrl) } + } + + @Transactional(readOnly = true) + fun login(request: LoginRequest): LoginResponse = + (userRepository.findByEmail(request.email) + ?.takeIf { passwordEncoder.matches(request.password, it.password) } + ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND)) + .also { if (!it.active) throw ApiException(UserExceptionCode.USER_NOT_ACTIVE) } + .let { + LoginResponse( + accessToken = jwtTokenProvider.generateAccessToken(it.id), + refreshToken = jwtTokenProvider.generateRefreshToken(it.id) + ) + } + + @Transactional(readOnly = true) + fun refreshToken(request: RefreshTokenRequest): RefreshTokenResponse = + request.refreshToken + .also { if (!jwtTokenProvider.validateRefreshToken(it)) throw ApiException(AuthExceptionCode.INVALID_REFRESH_TOKEN) } + .let { jwtTokenProvider.getUserIdFromRefreshToken(it) } + .let { userRepository.findById(it) ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) } + .also { if (!it.active) throw ApiException(UserExceptionCode.USER_NOT_ACTIVE) } + .let { + RefreshTokenResponse( + accessToken = jwtTokenProvider.generateAccessToken(it.id), + refreshToken = jwtTokenProvider.generateRefreshToken(it.id) + ) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/AuthApi.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/AuthApi.kt new file mode 100644 index 0000000..4db29c3 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/AuthApi.kt @@ -0,0 +1,48 @@ +package com.github.kusitms_bugi.domain.auth.presentation + +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.CheckEmailDuplicateRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.LoginRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.RefreshTokenRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.ResendVerificationEmailRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.SignupRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.VerifyEmailRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.CheckEmailDuplicateResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.LoginResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.RefreshTokenResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.SignupResponse +import com.github.kusitms_bugi.global.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping + +@Tag(name = "인증") +@RequestMapping("/auth") +interface AuthApi { + + @Operation(summary = "이메일 중복 확인") + @PostMapping("/check-email") + fun checkEmailDuplicate(@Validated @RequestBody request: CheckEmailDuplicateRequest): ApiResponse + + @Operation(summary = "회원가입") + @PostMapping("/sign-up") + fun signup(@Validated @RequestBody request: SignupRequest): ApiResponse + + @Operation(summary = "이메일 인증") + @PostMapping("/verify-email") + fun verifyEmail(@Validated @RequestBody request: VerifyEmailRequest): ApiResponse + + @Operation(summary = "인증 메일 재전송") + @PostMapping("/resend-verification-email") + fun resendVerificationEmail(@Validated @RequestBody request: ResendVerificationEmailRequest): ApiResponse + + @Operation(summary = "로그인") + @PostMapping("/login") + fun login(@Validated @RequestBody request: LoginRequest): ApiResponse + + @Operation(summary = "토큰 갱신") + @PostMapping("/refresh") + fun refreshToken(@Validated @RequestBody request: RefreshTokenRequest): ApiResponse +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/AuthController.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/AuthController.kt new file mode 100644 index 0000000..8f7a736 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/AuthController.kt @@ -0,0 +1,47 @@ +package com.github.kusitms_bugi.domain.auth.presentation + +import com.github.kusitms_bugi.domain.auth.application.AuthService +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.CheckEmailDuplicateRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.LoginRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.RefreshTokenRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.ResendVerificationEmailRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.SignupRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.request.VerifyEmailRequest +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.CheckEmailDuplicateResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.LoginResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.RefreshTokenResponse +import com.github.kusitms_bugi.domain.auth.presentation.dto.response.SignupResponse +import com.github.kusitms_bugi.global.response.ApiResponse +import org.springframework.web.bind.annotation.RestController + +@RestController +class AuthController( + private val authService: AuthService +) : AuthApi { + + override fun checkEmailDuplicate(request: CheckEmailDuplicateRequest): ApiResponse { + return ApiResponse.success(authService.checkEmailDuplicate(request)) + } + + override fun signup(request: SignupRequest): ApiResponse { + return ApiResponse.success(authService.signup(request)) + } + + override fun verifyEmail(request: VerifyEmailRequest): ApiResponse { + authService.verifyEmail(request) + return ApiResponse.success() + } + + override fun resendVerificationEmail(request: ResendVerificationEmailRequest): ApiResponse { + authService.resendVerificationEmail(request) + return ApiResponse.success() + } + + override fun login(request: LoginRequest): ApiResponse { + return ApiResponse.success(authService.login(request)) + } + + override fun refreshToken(request: RefreshTokenRequest): ApiResponse { + return ApiResponse.success(authService.refreshToken(request)) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/CheckEmailDuplicateRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/CheckEmailDuplicateRequest.kt new file mode 100644 index 0000000..ff1cd42 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/CheckEmailDuplicateRequest.kt @@ -0,0 +1,10 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +data class CheckEmailDuplicateRequest( + @field:NotBlank(message = "이메일은 필수입니다.") + @field:Email(message = "올바른 이메일 형식이 아닙니다.") + val email: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/LoginRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/LoginRequest.kt new file mode 100644 index 0000000..03bae75 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/LoginRequest.kt @@ -0,0 +1,13 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.request + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +data class LoginRequest( + @field:NotBlank(message = "이메일은 필수입니다.") + @field:Email(message = "올바른 이메일 형식이 아닙니다.") + val email: String, + + @field:NotBlank(message = "비밀번호는 필수입니다.") + val password: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/RefreshTokenRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/RefreshTokenRequest.kt new file mode 100644 index 0000000..eb16baa --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/RefreshTokenRequest.kt @@ -0,0 +1,8 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.request + +import jakarta.validation.constraints.NotBlank + +data class RefreshTokenRequest( + @field:NotBlank(message = "리프레시 토큰은 필수입니다.") + val refreshToken: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/ResendVerificationEmailRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/ResendVerificationEmailRequest.kt new file mode 100644 index 0000000..c1601f1 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/ResendVerificationEmailRequest.kt @@ -0,0 +1,15 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.request + +import com.github.kusitms_bugi.global.validator.AllowedOrigin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +data class ResendVerificationEmailRequest( + @field:NotBlank(message = "이메일은 필수입니다.") + @field:Email(message = "올바른 이메일 형식이 아닙니다.") + val email: String, + + @field:NotBlank(message = "콜백 URL은 필수입니다.") + @field:AllowedOrigin + val callbackUrl: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/SignupRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/SignupRequest.kt new file mode 100644 index 0000000..d175f4b --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/SignupRequest.kt @@ -0,0 +1,34 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.request + +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import com.github.kusitms_bugi.global.validator.AllowedOrigin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class SignupRequest( + @field:NotBlank(message = "이름은 필수입니다.") + val name: String, + + @field:NotBlank(message = "이메일은 필수입니다.") + @field:Email(message = "올바른 이메일 형식이 아닙니다.") + val email: String, + + @field:NotBlank(message = "비밀번호는 필수입니다.") + @field:Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") + val password: String, + + @field:NotBlank(message = "콜백 URL은 필수입니다.") + @field:AllowedOrigin + val callbackUrl: String, + + val avatar: String? = null +) + +fun SignupRequest.toEntity(password: String) = User( + name = this.name, + email = this.email, + password = password, + avatar = this.avatar, + active = false +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/VerifyEmailRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/VerifyEmailRequest.kt new file mode 100644 index 0000000..68bcc46 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/VerifyEmailRequest.kt @@ -0,0 +1,8 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.request + +import jakarta.validation.constraints.NotBlank + +data class VerifyEmailRequest( + @field:NotBlank(message = "토큰은 필수입니다.") + val token: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/CheckEmailDuplicateResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/CheckEmailDuplicateResponse.kt new file mode 100644 index 0000000..b2e8277 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/CheckEmailDuplicateResponse.kt @@ -0,0 +1,5 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.response + +data class CheckEmailDuplicateResponse( + val isDuplicate: Boolean +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/LoginResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/LoginResponse.kt new file mode 100644 index 0000000..3527a1b --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/LoginResponse.kt @@ -0,0 +1,6 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.response + +data class LoginResponse( + val accessToken: String, + val refreshToken: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/RefreshTokenResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/RefreshTokenResponse.kt new file mode 100644 index 0000000..a92b7cb --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/RefreshTokenResponse.kt @@ -0,0 +1,6 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.response + +data class RefreshTokenResponse( + val accessToken: String, + val refreshToken: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/SignupResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/SignupResponse.kt new file mode 100644 index 0000000..d86f03b --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/SignupResponse.kt @@ -0,0 +1,18 @@ +package com.github.kusitms_bugi.domain.auth.presentation.dto.response + +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import java.util.* + +data class SignupResponse( + val id: UUID, + val name: String, + val email: String, + val avatar: String? +) + +fun User.toResponse() = SignupResponse( + id = this.id, + name = this.name, + email = this.email, + avatar = this.avatar +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AttendanceService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AttendanceService.kt new file mode 100644 index 0000000..18149e1 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AttendanceService.kt @@ -0,0 +1,158 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetAttendanceRequest +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.AttendanceResponse +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionJpaRepository +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.YearMonth +import kotlin.math.round + +data class DailyStats( + val date: LocalDate, + val activeMinutes: Int, + val badDurationMillis: Long, + val goodDurationMillis: Long, + val totalDurationMillis: Long, + val avgLevel: Double +) + +@Service +@Transactional(readOnly = true) +class AttendanceService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getAttendance(user: User, request: GetAttendanceRequest): AttendanceResponse { + val yearMonth = YearMonth.of(request.year, request.month) + val today = LocalDate.now() + + val earliestDate = minOf( + yearMonth.atDay(1), + today.minusDays(13) + ) + + val allStats = sessionJpaRepository.getAttendanceStats( + user.id, + earliestDate.atStartOfDay(), + yearMonth.atEndOfMonth().atTime(23, 59, 59) + ).map { result -> + DailyStats( + date = LocalDate.parse(result[0] as String), + activeMinutes = (result[1] as Number).toInt(), + badDurationMillis = (result[2] as Number).toLong(), + goodDurationMillis = (result[3] as Number).toLong(), + totalDurationMillis = (result[4] as Number).toLong(), + avgLevel = (result[5] as Number).toDouble() + ) + }.associateBy { it.date } + + val attendances = (1..yearMonth.lengthOfMonth()).associate { day -> + val date = yearMonth.atDay(day) + val value = when { + date.isAfter(today) -> null + else -> allStats[date]?.activeMinutes ?: 0 + } + date to value + } + + val recentStats = (0..6).mapNotNull { offset -> + allStats[today.minusDays(offset.toLong())] + } + + val previousStats = (7..13).mapNotNull { offset -> + allStats[today.minusDays(offset.toLong())] + } + + val title = calculateTitle(recentStats, previousStats) + val content1 = calculateContent1(recentStats, allStats.values.toList()) + val content2 = calculateContent2(recentStats, allStats.values.toList()) + val subContent = calculateSubContent(recentStats) + + return AttendanceResponse( + attendances = attendances, + title = title, + content1 = content1, + content2 = content2, + subContent = subContent + ) + } + + private fun calculateTitle(recentStats: List, previousStats: List): String { + val recentBadTime = recentStats.sumOf { it.badDurationMillis } + val previousBadTime = previousStats.sumOf { it.badDurationMillis } + val recentGoodRatio = calculateGoodRatio(recentStats) + val previousGoodRatio = calculateGoodRatio(previousStats) + + val badTimeDecreased = recentBadTime < previousBadTime + val goodRatioIncreased = recentGoodRatio > previousGoodRatio + + return when { + badTimeDecreased && goodRatioIncreased -> "잘하고 있어요!" + !badTimeDecreased && !goodRatioIncreased -> "주의가 필요해요!" + else -> "조금만 더 힘내봐요!" + } + } + + private fun calculateContent1(recentStats: List, allStats: List): String { + // Use average level as a proxy for score comparison + val myAverageLevel = recentStats + .filter { it.avgLevel > 0 } + .map { it.avgLevel } + .takeIf { it.isNotEmpty() } + ?.average() ?: 3.5 + + val allLevels = allStats + .filter { it.avgLevel > 0 } + .map { it.avgLevel } + .takeIf { it.isNotEmpty() } ?: listOf(3.5) + + val worseCount = allLevels.count { level -> level > myAverageLevel } + val percentile = if (allLevels.isNotEmpty()) { + round((worseCount.toDouble() / allLevels.size) * 100).toInt() + } else { + 50 + } + + return "전체 사용자 중, 당신의 자세는 상위 ${percentile}%예요" + } + + private fun calculateContent2(recentStats: List, allStats: List): String { + val myGoodRatio = calculateGoodRatio(recentStats) + val allUsersGoodRatio = calculateGoodRatio(allStats) + + val difference = round(myGoodRatio - allUsersGoodRatio).toInt() + + return when { + difference > 0 -> "다른 사용자보다 바른 자세 비율이 ${difference}% 높아요" + difference < 0 -> "다른 사용자보다 거북목이 평균 ${-difference}% 더 적어요" + else -> "다른 사용자와 비슷한 자세 비율이에요" + } + } + + private fun calculateSubContent(stats: List): String { + val averageLevel = stats + .filter { it.avgLevel > 0 } + .map { it.avgLevel } + .takeIf { it.isNotEmpty() } + ?.average() ?: 3.0 + + return when { + averageLevel <= 1.5 -> "꼿꼿기린" + averageLevel <= 2.5 -> "쑥쑥기린" + averageLevel <= 3.5 -> "아기기린" + averageLevel <= 4.5 -> "꾸부정거부기" + else -> "뽀각거부기" + } + } + + private fun calculateGoodRatio(stats: List): Double { + val totalDuration = stats.sumOf { it.totalDurationMillis }.toDouble() + if (totalDuration == 0.0) return 0.0 + + val goodDuration = stats.sumOf { it.goodDurationMillis } + return (goodDuration / totalDuration) * 100.0 + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AverageScoreService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AverageScoreService.kt new file mode 100644 index 0000000..afe1e03 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AverageScoreService.kt @@ -0,0 +1,33 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.AverageScoreResponse +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionJpaRepository +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class AverageScoreService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getAverageScore(user: User): AverageScoreResponse { + val thirtyDaysAgo = LocalDateTime.now().minusDays(ANALYSIS_PERIOD_DAYS) + + val averageScore = sessionJpaRepository.getAverageCompositeScore( + user.id, + thirtyDaysAgo + ) ?: DEFAULT_SCORE + + return AverageScoreResponse(score = averageScore.coerceIn(MIN_SCORE, MAX_SCORE)) + } + + companion object { + private const val ANALYSIS_PERIOD_DAYS = 30L + private const val DEFAULT_SCORE = 50 + private const val MIN_SCORE = 1 + private const val MAX_SCORE = 100 + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/HighlightService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/HighlightService.kt new file mode 100644 index 0000000..05edde9 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/HighlightService.kt @@ -0,0 +1,73 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetHighlightRequest +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.HighlightResponse +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionJpaRepository +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.temporal.TemporalAdjusters + +@Service +@Transactional(readOnly = true) +class HighlightService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getHighlight(user: User, request: GetHighlightRequest): HighlightResponse { + return when (request.period) { + GetHighlightRequest.Period.WEEKLY -> calculateWeeklyHighlight(user) + GetHighlightRequest.Period.MONTHLY -> calculateMonthlyHighlight(user) + } + } + + private fun calculateWeeklyHighlight(user: User): HighlightResponse { + val today = LocalDate.now() + val thisWeekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val lastWeekStart = thisWeekStart.minusWeeks(1) + val lastWeekEnd = thisWeekStart.minusDays(1) + + val current = sessionJpaRepository.getHighlightStats( + user.id, + thisWeekStart.atStartOfDay(), + today.atTime(23, 59, 59) + ) ?: 0 + + val previous = sessionJpaRepository.getHighlightStats( + user.id, + lastWeekStart.atStartOfDay(), + lastWeekEnd.atTime(23, 59, 59) + ) ?: 0 + + return HighlightResponse( + current = current, + previous = previous + ) + } + + private fun calculateMonthlyHighlight(user: User): HighlightResponse { + val today = LocalDate.now() + val thisMonth = YearMonth.from(today) + val lastMonth = thisMonth.minusMonths(1) + + val current = sessionJpaRepository.getHighlightStats( + user.id, + thisMonth.atDay(1).atStartOfDay(), + thisMonth.atEndOfMonth().atTime(23, 59, 59) + ) ?: 0 + + val previous = sessionJpaRepository.getHighlightStats( + user.id, + lastMonth.atDay(1).atStartOfDay(), + lastMonth.atEndOfMonth().atTime(23, 59, 59) + ) ?: 0 + + return HighlightResponse( + current = current, + previous = previous + ) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/LevelService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/LevelService.kt new file mode 100644 index 0000000..3a69eb2 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/LevelService.kt @@ -0,0 +1,86 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.LevelResponse +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionJpaRepository +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.* +import kotlin.math.pow + +@Service +@Transactional(readOnly = true) +class LevelService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getLevel(user: User): LevelResponse { + val totalDistance = calculateTotalDistanceFromDb(user.id) + val currentLevel = calculateLevel(totalDistance) + val levelStartDistance = calculateLevelStartDistance(currentLevel) + val currentProgress = (totalDistance - levelStartDistance).toInt() + val requiredDistance = calculateRequiredDistance(currentLevel) + + return LevelResponse( + level = currentLevel, + current = currentProgress, + required = requiredDistance + ) + } + + private fun calculateTotalDistanceFromDb(userId: UUID): Double { + val levelDurations = sessionJpaRepository.calculateLevelDurationsForUser(userId) + .associate { result -> + (result[0] as Number).toInt() to (result[1] as Number).toLong() + } + + return LEVEL_SPEEDS.entries.sumOf { (level, speed) -> + val durationMillis = levelDurations[level] ?: 0L + val durationHours = durationMillis / 1000.0 / 3600.0 + durationHours * speed + } + } + + private fun calculateLevel(totalDistance: Double): Int { + var accumulatedDistance = 0.0 + var level = 1 + + while (level <= MAX_LEVEL) { + val requiredForNextLevel = BASE_DISTANCE * (GROWTH_RATE.pow(level - 1)) + if (totalDistance < accumulatedDistance + requiredForNextLevel) { + return level + } + accumulatedDistance += requiredForNextLevel + level++ + } + + return MAX_LEVEL + } + + private fun calculateLevelStartDistance(level: Int): Double { + if (level == 1) return 0.0 + + return (1 until level).sumOf { l -> + BASE_DISTANCE * (GROWTH_RATE.pow(l - 1)) + } + } + + private fun calculateRequiredDistance(level: Int): Int { + return (BASE_DISTANCE * (GROWTH_RATE.pow(level - 1))).toInt() + } + + companion object { + private val LEVEL_SPEEDS = mapOf( + 1 to 3000.0, + 2 to 1500.0, + 3 to 500.0, + 4 to 200.0, + 5 to 50.0, + 6 to 0.1 + ) + + private const val BASE_DISTANCE = 1500.0 + private const val GROWTH_RATE = 1.2 + private const val MAX_LEVEL = 100 + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PostureGraphService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PostureGraphService.kt new file mode 100644 index 0000000..ece2806 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PostureGraphService.kt @@ -0,0 +1,38 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.PostureGraphResponse +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionJpaRepository +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Service +@Transactional(readOnly = true) +class PostureGraphService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getPostureGraph(user: User): PostureGraphResponse { + val today = LocalDate.now() + val startDate = today.minusDays(30) + + val days = (0..30).map { offset -> + today.minusDays(offset.toLong()) + }.reversed() + + val scoresByDate = sessionJpaRepository.getAverageScoresByDate( + user.id, + startDate.atStartOfDay(), + today.atTime(23, 59, 59) + ).associate { result -> + LocalDate.parse(result[0] as String) to (result[1] as Number).toInt() + } + + val points = days.associateWith { day -> + scoresByDate[day] ?: 0 + } + + return PostureGraphResponse(points) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PosturePatternService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PosturePatternService.kt new file mode 100644 index 0000000..0bbab66 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PosturePatternService.kt @@ -0,0 +1,109 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.PosturePatternResponse +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionJpaRepository +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalTime + +data class PosturePatternStats( + val hourOfDay: Int, + val dayOfWeek: Int, + val badPostureDurationMillis: Long, + val goodPostureDurationMillis: Long, + val recoveryCount: Int, + val avgRecoveryTimeMillis: Long +) + +@Service +@Transactional(readOnly = true) +class PosturePatternService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getPosturePattern(user: User): PosturePatternResponse { + val sevenDaysAgo = LocalDate.now().minusDays(6) + + val stats = sessionJpaRepository.getPosturePatternStats( + user.id, + sevenDaysAgo.atStartOfDay(), + LocalDate.now().atTime(23, 59, 59) + ).map { result -> + PosturePatternStats( + hourOfDay = (result[0] as Number).toInt(), + dayOfWeek = (result[1] as Number).toInt(), + badPostureDurationMillis = (result[2] as Number).toLong(), + goodPostureDurationMillis = (result[3] as Number).toLong(), + recoveryCount = (result[4] as Number).toInt(), + avgRecoveryTimeMillis = (result[5] as Number).toLong() + ) + } + + val worstTime = calculateWorstTimeFromStats(stats) + val worstDay = calculateWorstDayFromStats(stats) + val recovery = calculateRecoveryFromStats(stats) + + return PosturePatternResponse( + worstTime = worstTime, + worstDay = worstDay, + recovery = recovery, + stretching = getRandomStretching(user) + ) + } + + private fun calculateWorstTimeFromStats(stats: List): LocalTime { + val goodPostureDurationByHour = stats + .groupBy { it.hourOfDay } + .mapValues { (_, hourStats) -> hourStats.sumOf { it.goodPostureDurationMillis } } + + val worstHour = goodPostureDurationByHour.maxByOrNull { it.value }?.key ?: 12 + return LocalTime.of(worstHour, 0, 0) + } + + private fun calculateWorstDayFromStats(stats: List): DayOfWeek { + val goodPostureDurationByDay = stats + .groupBy { it.dayOfWeek } + .mapValues { (_, dayStats) -> dayStats.sumOf { it.goodPostureDurationMillis } } + + val worstDayNum = goodPostureDurationByDay.maxByOrNull { it.value }?.key ?: 1 + return when (worstDayNum) { + 0 -> DayOfWeek.SUNDAY + else -> DayOfWeek.of(worstDayNum) + } + } + + private fun calculateRecoveryFromStats(stats: List): Int { + val totalRecoveryTime = stats.sumOf { it.avgRecoveryTimeMillis * it.recoveryCount } + val totalRecoveryCount = stats.sumOf { it.recoveryCount } + + if (totalRecoveryCount == 0) return 0 + + val averageRecoveryMillis = totalRecoveryTime.toDouble() / totalRecoveryCount + return (averageRecoveryMillis / 1000).toInt() + } + + private fun getRandomStretching(user: User): String { + val today = LocalDate.now() + val seed = "${user.id}-${today}".hashCode() + val index = (seed and 0x7fffffff) % STRETCHING_LIST.size + return STRETCHING_LIST[index] + } + + companion object { + private val STRETCHING_LIST = listOf( + "턱 당기기", + "목 측면 스트레칭", + "목 전방 굴곡 스트레칭", + "목 후방 신전", + "날개뼈 모으기", + "벽 밀기", + "가슴 펴기", + "손깍지 끼고 가슴 열기", + "흉추 신전", + "고양이-낙타 자세" + ) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/ScoreService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/ScoreService.kt new file mode 100644 index 0000000..32ec228 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/ScoreService.kt @@ -0,0 +1,244 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionMetric +import org.springframework.stereotype.Service +import java.time.Duration +import java.time.LocalDateTime +import kotlin.math.* + +@Service +class ScoreService { + + fun calculateScoreFromSession(session: Session): Int { + val sessionLengthMinutes = calculateSessionLengthMinutes(session) + if (sessionLengthMinutes < 5.0) return 50 + + val activeMetrics = session.getActiveMetrics() + val levelDurations = session.getLevelDurations() + + val raw = calculateRawScore(levelDurations) + val goodStreakBonus = calculateGoodStreakBonus(session, activeMetrics, sessionLengthMinutes) + val badStreakPenalty = calculateBadStreakPenalty(session, activeMetrics, sessionLengthMinutes) + val transitionPenalty = calculateTransitionPenalty(activeMetrics, sessionLengthMinutes) + val usageBoost = calculateUsageBoost(sessionLengthMinutes) + val tilt = calculateTilt(levelDurations, sessionLengthMinutes) + + val preScore = raw + goodStreakBonus + badStreakPenalty + transitionPenalty + usageBoost + tilt + val stabilized = stabilizeForShortSession(preScore, sessionLengthMinutes) + val adaptiveFloor = calculateAdaptiveFloor(levelDurations, sessionLengthMinutes) + val finalScore = max(adaptiveFloor, stabilized) + + return round(finalScore).toInt().coerceIn(1, 100) + } + + private fun calculateSessionLengthMinutes(session: Session): Double { + return session.calculateTotalActiveSeconds() / 60.0 + } + + private fun calculateFlips(activeMetrics: List): Int { + if (activeMetrics.size < 2) return 0 + + var flips = 0 + var previousSide = if (activeMetrics.first().score <= 3) "good" else "bad" + + for (i in 1 until activeMetrics.size) { + val currentSide = if (activeMetrics[i].score <= 3) "good" else "bad" + if (currentSide != previousSide) { + flips++ + previousSide = currentSide + } + } + + return flips + } + + private fun extractStreaks( + activeMetrics: List, + session: Session, + condition: (Int) -> Boolean + ): List { + if (activeMetrics.isEmpty()) return emptyList() + + // Pause 구간들을 한 번만 미리 계산하여 재사용 (성능 최적화) + val pauseRanges = getPauseRangesFromSession(session) + + val streaks = mutableListOf() + var streakStartTime: LocalDateTime? = null + + activeMetrics.forEach { metric -> + if (condition(metric.score)) { + if (streakStartTime == null) { + streakStartTime = metric.timestamp + } + } else { + streakStartTime?.let { start -> + val activeDuration = calculateActiveDuration(start, metric.timestamp, pauseRanges) + if (activeDuration > 0) { + streaks.add(activeDuration) + } + streakStartTime = null + } + } + } + + streakStartTime?.let { start -> + val lastMetric = activeMetrics.last() + val activeDuration = calculateActiveDuration(start, lastMetric.timestamp, pauseRanges) + if (activeDuration > 0) { + streaks.add(activeDuration) + } + } + + return streaks + } + + private fun getPauseRangesFromSession(session: Session): List> { + val ranges = mutableListOf>() + var pauseStartTime: LocalDateTime? = null + + session.statusHistory.forEach { history -> + when (history.status) { + com.github.kusitms_bugi.domain.session.domain.SessionStatus.PAUSED -> { + pauseStartTime = history.timestamp + } + com.github.kusitms_bugi.domain.session.domain.SessionStatus.RESUMED -> { + pauseStartTime?.let { pauseStart -> + ranges.add(pauseStart to history.timestamp) + pauseStartTime = null + } + } + else -> {} + } + } + + return ranges + } + + private fun calculateActiveDuration( + start: LocalDateTime, + end: LocalDateTime, + pauseRanges: List> + ): Double { + val duration = Duration.between(start, end) + var pausedMillis = 0L + + pauseRanges.forEach { (pauseStart, pauseEnd) -> + val overlapStart = maxOf(start, pauseStart) + val overlapEnd = minOf(end, pauseEnd) + + if (overlapStart < overlapEnd) { + pausedMillis += java.time.temporal.ChronoUnit.MILLIS.between(overlapStart, overlapEnd) + } + } + + return (duration.toMillis() - pausedMillis) / 1000.0 / 60.0 + } + + private fun calculateRawScore(levelDurations: Map): Double { + val totalDuration = levelDurations.values.sum().toDouble() + if (totalDuration == 0.0) return 50.0 + + val percentages = (1..6).map { level -> + (levelDurations.getOrDefault(level, 0L) / totalDuration) * 100.0 + } + + val weights = listOf(30.0, 22.0, 12.0, -15.0, -40.0, -70.0) + val weightedSum = percentages.zip(weights).sumOf { (percentage, weight) -> + percentage * weight + } + + return 50.0 + weightedSum / 100.0 + } + + private fun calculateGoodStreakBonus( + session: Session, + activeMetrics: List, + sessionLength: Double + ): Double { + val goodStreaks = extractStreaks(activeMetrics, session) { score -> score <= 3 } + if (goodStreaks.isEmpty()) return 0.0 + + val baseGood = goodStreaks.sumOf { streak -> + when { + streak >= 20.0 -> 10.0 + streak >= 10.0 -> 5.0 + streak >= 5.0 -> 2.0 + else -> 0.0 + } + } + + val maxStreak = goodStreaks.max() + val goodFactor = 1 + 0.4 * min(1.0, maxStreak / 60.0) + val capGood = 18.0 + 10.0 * min(1.0, sessionLength / 480.0) + + return min(capGood, baseGood * goodFactor) + } + + private fun calculateBadStreakPenalty( + session: Session, + activeMetrics: List, + sessionLength: Double + ): Double { + val badStreaks = extractStreaks(activeMetrics, session) { score -> score >= 4 } + if (badStreaks.isEmpty()) return 0.0 + + val baseBad = badStreaks.sumOf { streak -> + when { + streak >= 20.0 -> -20.0 + streak >= 10.0 -> -10.0 + streak >= 3.0 -> -4.0 + else -> 0.0 + } + } + + val maxStreak = badStreaks.max() + val badFactorBase = 1 + 0.8 * min(1.0, maxStreak / 30.0) + val badFactorAdj = badFactorBase * min(1.0, sessionLength / 240.0) + val capBad = 12.0 + 23.0 * min(1.0, sessionLength / 240.0) + + return -min(capBad, abs(baseBad) * badFactorAdj) + } + + private fun calculateTransitionPenalty(activeMetrics: List, sessionLength: Double): Double { + val flips = calculateFlips(activeMetrics) + val threshold = 8.0 * (sessionLength / 60.0) + return -max(0.0, flips - threshold) + } + + private fun calculateUsageBoost(sessionLength: Double): Double { + return 12.0 * (1 - exp(-sessionLength / 120.0)) + } + + private fun calculateTilt(levelDurations: Map, sessionLength: Double): Double { + val totalDuration = levelDurations.values.sum().toDouble() + if (totalDuration == 0.0) return 0.0 + + val p1 = (levelDurations.getOrDefault(1, 0L) / totalDuration) * 100.0 + val p2 = (levelDurations.getOrDefault(2, 0L) / totalDuration) * 100.0 + + val sGood = (p1 + p2) / 100.0 + val lengthFactor = min(1.0, sessionLength / 480.0) + + return lengthFactor * (18.0 * (sGood - 0.5) - 5.0 * max(0.0, sGood - 0.8)) + } + + private fun stabilizeForShortSession(preScore: Double, sessionLength: Double): Double { + val stabilizationFactor = min(1.0, sessionLength / 20.0) + return 50.0 + stabilizationFactor * (preScore - 50.0) + } + + private fun calculateAdaptiveFloor(levelDurations: Map, sessionLength: Double): Double { + val totalDuration = levelDurations.values.sum().toDouble() + if (totalDuration == 0.0) return 10.0 + + val p1 = (levelDurations.getOrDefault(1, 0L) / totalDuration) * 100.0 + val p2 = (levelDurations.getOrDefault(2, 0L) / totalDuration) * 100.0 + val p3 = (levelDurations.getOrDefault(3, 0L) / totalDuration) * 100.0 + + val mixFloor = 10.0 + 0.2 * (p1 + p2) + 0.1 * p3 + val timeScaler = 1.0 - exp(-sessionLength / 60.0) + + return round(timeScaler * mixFloor) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/DashboardApi.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/DashboardApi.kt new file mode 100644 index 0000000..acda6a1 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/DashboardApi.kt @@ -0,0 +1,57 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetAttendanceRequest +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetHighlightRequest +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.* +import com.github.kusitms_bugi.global.response.ApiResponse +import com.github.kusitms_bugi.global.security.CustomUserDetails +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping + +@Tag(name = "대시보드") +@RequestMapping("/dashboard") +interface DashboardApi { + + @Operation(summary = "평균 자세 점수 조회",) + @GetMapping("/average-score") + fun getAverageScore( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "출석 현황 조회") + @GetMapping("/attendance") + fun getAttendance( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated request: GetAttendanceRequest + ): ApiResponse + + @Operation(summary = "레벨 도달 현황 조회") + @GetMapping("/level") + fun getLevel( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "바른 자세 점수 그래프 조회 (최근 31일)") + @GetMapping("/posture-graph") + fun getPostureGraph( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "하이라이트 조회") + @GetMapping("/highlight") + fun getHighlight( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated request: GetHighlightRequest + ): ApiResponse + + @Operation(summary = "자세 패턴 분석 조회") + @GetMapping("/posture-pattern") + fun getPosturePattern( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/DashboardController.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/DashboardController.kt new file mode 100644 index 0000000..1087071 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/DashboardController.kt @@ -0,0 +1,60 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation + +import com.github.kusitms_bugi.domain.dashboard.application.* +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetAttendanceRequest +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetHighlightRequest +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.* +import com.github.kusitms_bugi.global.response.ApiResponse +import com.github.kusitms_bugi.global.security.CustomUserDetails +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.RestController + +@RestController +class DashboardController( + private val averageScoreService: AverageScoreService, + private val attendanceService: AttendanceService, + private val levelService: LevelService, + private val postureGraphService: PostureGraphService, + private val highlightService: HighlightService, + private val posturePatternService: PosturePatternService +) : DashboardApi { + + override fun getAverageScore( + @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse { + return ApiResponse.success(averageScoreService.getAverageScore(userDetails.user)) + } + + override fun getAttendance( + @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated request: GetAttendanceRequest + ): ApiResponse { + return ApiResponse.success(attendanceService.getAttendance(userDetails.user, request)) + } + + override fun getLevel( + @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse { + return ApiResponse.success(levelService.getLevel(userDetails.user)) + } + + override fun getPostureGraph( + @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse { + return ApiResponse.success(postureGraphService.getPostureGraph(userDetails.user)) + } + + override fun getHighlight( + @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated request: GetHighlightRequest + ): ApiResponse { + return ApiResponse.success(highlightService.getHighlight(userDetails.user, request)) + } + + override fun getPosturePattern( + @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse { + return ApiResponse.success(posturePatternService.getPosturePattern(userDetails.user)) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetAttendanceRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetAttendanceRequest.kt new file mode 100644 index 0000000..52e7bae --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetAttendanceRequest.kt @@ -0,0 +1,25 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull +import org.springdoc.core.annotations.ParameterObject + +@ParameterObject +@Schema(description = "출석 현황 조회 요청") +data class GetAttendanceRequest( + @field:Schema(description = "조회 기간") + @field:NotNull(message = "조회 기간은 필수입니다") + var period: Period, + + @field:Schema(description = "조회 년도") + @field:NotNull(message = "년도는 필수입니다") + var year: Int, + + @field:Schema(description = "조회 월") + @field:NotNull(message = "월은 필수입니다") + var month: Int +) { + enum class Period { + MONTHLY + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetHighlightRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetHighlightRequest.kt new file mode 100644 index 0000000..aefb65c --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetHighlightRequest.kt @@ -0,0 +1,17 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull +import org.springdoc.core.annotations.ParameterObject + +@ParameterObject +@Schema(description = "하이라이트 조회 요청 DTO") +data class GetHighlightRequest( + @field:Schema(description = "조회 기간") + @field:NotNull(message = "조회 기간은 필수입니다") + var period: Period +) { + enum class Period { + WEEKLY, MONTHLY + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/AttendanceResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/AttendanceResponse.kt new file mode 100644 index 0000000..6503186 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/AttendanceResponse.kt @@ -0,0 +1,37 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +@Schema(description = "출석 현황 응답") +data class AttendanceResponse( + @field:Schema( + description = "출석 데이터 (오늘 이전 날짜는 데이터 없으면 0, 오늘 이후 날짜는 null)", + example = "{\"2025-01-01\": 3, \"2025-01-02\": 5, \"2025-01-03\": 2, \"2025-01-25\": null}" + ) + val attendances: Map, + + @field:Schema( + description = "피드백 제목", + example = "잘하고 있어요!" + ) + val title: String, + + @field:Schema( + description = "전체 사용자와의 점수 비교", + example = "전체 사용자 중, 당신의 자세는 상위 25%예요" + ) + val content1: String, + + @field:Schema( + description = "전체 사용자와의 자세 비율 비교", + example = "다른 사용자보다 바른 자세 비율이 15% 높아요" + ) + val content2: String, + + @field:Schema( + description = "목 하중 상태", + example = "꾸부정거부기" + ) + val subContent: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/AverageScoreResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/AverageScoreResponse.kt new file mode 100644 index 0000000..6900635 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/AverageScoreResponse.kt @@ -0,0 +1,9 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "평균 자세 점수 응답") +data class AverageScoreResponse( + @field:Schema(description = "평균 자세 점수", example = "47") + val score: Int +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/HighlightResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/HighlightResponse.kt new file mode 100644 index 0000000..1c90234 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/HighlightResponse.kt @@ -0,0 +1,12 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "하이라이트 응답") +data class HighlightResponse( + @field:Schema(description = "이번 주/월 평균 사용 시간 (분)", example = "321") + val current: Int, + + @field:Schema(description = "저번 주/월 평균 사용 시간 (분)", example = "257") + val previous: Int, +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/LevelResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/LevelResponse.kt new file mode 100644 index 0000000..9f77490 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/LevelResponse.kt @@ -0,0 +1,15 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "레벨 도달 현황 응답") +data class LevelResponse( + @field:Schema(description = "레벨", example = "1") + val level: Int, + + @field:Schema(description = "현재 이동 거리", example = "400") + val current: Int, + + @field:Schema(description = "필요한 거리", example = "1200") + val required: Int, +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/PostureGraphResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/PostureGraphResponse.kt new file mode 100644 index 0000000..59dcd04 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/PostureGraphResponse.kt @@ -0,0 +1,13 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +@Schema(description = "바른 자세 점수 그래프 응답") +data class PostureGraphResponse( + @field:Schema( + description = "시계열 데이터 포인트 (날짜: 점수)", + example = "{\"2025-01-01\": 85, \"2025-01-02\": 92, \"2025-01-03\": 78}" + ) + val points: Map +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/PosturePatternResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/PosturePatternResponse.kt new file mode 100644 index 0000000..f1d63c2 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/PosturePatternResponse.kt @@ -0,0 +1,20 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.DayOfWeek +import java.time.LocalTime + +@Schema(description = "자세 패턴 분석 응답") +data class PosturePatternResponse( + @field:Schema(description = "안 좋은 시간대", example = "14:00:00") + val worstTime: LocalTime, + + @field:Schema(description = "안 좋은 요일", example = "FRIDAY") + val worstDay: DayOfWeek, + + @field:Schema(description = "회복 탄력성 (분)", example = "3") + val recovery: Int, + + @field:Schema(description = "추천 스트레칭", example = "목돌리기") + val stretching: String, +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/application/SessionService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/application/SessionService.kt new file mode 100644 index 0000000..4afc513 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/application/SessionService.kt @@ -0,0 +1,155 @@ +package com.github.kusitms_bugi.domain.session.application + +import com.github.kusitms_bugi.domain.dashboard.application.ScoreService +import com.github.kusitms_bugi.domain.session.domain.SessionStatus +import com.github.kusitms_bugi.domain.session.infrastructure.SessionRepositoryImpl +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionMetric +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionStatusHistory +import com.github.kusitms_bugi.domain.session.presentation.dto.request.MetricData +import com.github.kusitms_bugi.domain.session.presentation.dto.response.CreateSessionResponse +import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessionReportResponse +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import com.github.kusitms_bugi.global.exception.ApiException +import com.github.kusitms_bugi.global.exception.SessionExceptionCode +import com.github.kusitms_bugi.global.security.CustomUserDetails +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.* + +@Service +class SessionService( + private val sessionRepository: SessionRepositoryImpl, + private val scoreService: ScoreService +) { + + @Transactional(readOnly = true) + fun getSession(sessionId: UUID, userDetails: CustomUserDetails): GetSessionReportResponse { + val session = sessionRepository.findByIdWithDetails(sessionId) + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_FOUND) + + if (session.user.id != userDetails.getId()) { + throw ApiException(SessionExceptionCode.SESSION_ACCESS_DENIED) + } + + session.lastStatus() + .takeIf { it == SessionStatus.STOPPED } + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_STOPPED) + + return GetSessionReportResponse( + totalSeconds = session.calculateTotalActiveSeconds(), + goodSeconds = session.calculateGoodSeconds(), + score = scoreService.calculateScoreFromSession(session) + ) + } + + @Transactional + fun createSession(user: User): CreateSessionResponse { + val session = Session(user = user) + + session.statusHistory.add( + SessionStatusHistory( + session = session, + status = SessionStatus.STARTED + ) + ) + + return CreateSessionResponse(sessionId = sessionRepository.save(session).id) + } + + @Transactional + fun pauseSession(sessionId: UUID, userDetails: CustomUserDetails) { + val session = sessionRepository.findByIdWithStatusHistory(sessionId) + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_FOUND) + + if (session.user.id != userDetails.getId()) { + throw ApiException(SessionExceptionCode.SESSION_ACCESS_DENIED) + } + + session.lastStatus() + .takeIf { it == SessionStatus.STARTED || it == SessionStatus.RESUMED } + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_RESUMED) + + session.statusHistory.add( + SessionStatusHistory( + session = session, + status = SessionStatus.PAUSED + ) + ) + + sessionRepository.save(session) + } + + @Transactional + fun resumeSession(sessionId: UUID, userDetails: CustomUserDetails) { + val session = sessionRepository.findByIdWithStatusHistory(sessionId) + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_FOUND) + + if (session.user.id != userDetails.getId()) { + throw ApiException(SessionExceptionCode.SESSION_ACCESS_DENIED) + } + + session.lastStatus() + .takeIf { it == SessionStatus.PAUSED } + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_PAUSED) + + session.statusHistory.add( + SessionStatusHistory( + session = session, + status = SessionStatus.RESUMED + ) + ) + + sessionRepository.save(session) + } + + @Transactional + fun stopSession(sessionId: UUID, userDetails: CustomUserDetails) { + val session = sessionRepository.findByIdWithDetails(sessionId) + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_FOUND) + + if (session.user.id != userDetails.getId()) { + throw ApiException(SessionExceptionCode.SESSION_ACCESS_DENIED) + } + + session.lastStatus() + .takeUnless { it == SessionStatus.STOPPED } + ?: throw ApiException(SessionExceptionCode.SESSION_ALREADY_STOPPED) + + session.statusHistory.add( + SessionStatusHistory( + session = session, + status = SessionStatus.STOPPED + ) + ) + + session.score = scoreService.calculateScoreFromSession(session) + + sessionRepository.save(session) + } + + @Transactional + fun saveMetrics(sessionId: UUID, userDetails: CustomUserDetails, request: Collection) { + // statusHistory와 metrics 모두 필요하므로 findByIdWithDetails 사용 + val session = sessionRepository.findByIdWithDetails(sessionId) + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_FOUND) + + if (session.user.id != userDetails.getId()) { + throw ApiException(SessionExceptionCode.SESSION_ACCESS_DENIED) + } + + session.lastStatus() + .takeIf { it == SessionStatus.STARTED || it == SessionStatus.RESUMED } + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_ACTIVE) + + request.forEach { + SessionMetric( + session = session, + score = it.score, + timestamp = it.timestamp + ).let { metric -> session.metrics.add(metric) } + } + + sessionRepository.save(session) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/Session.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/Session.kt new file mode 100644 index 0000000..593f4d3 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/Session.kt @@ -0,0 +1,31 @@ +package com.github.kusitms_bugi.domain.session.domain + +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import com.github.kusitms_bugi.global.entity.BaseField +import java.time.LocalDateTime + +enum class SessionStatus { + STARTED, + PAUSED, + RESUMED, + STOPPED +} + +interface SessionField : BaseField { + var user: User + var statusHistory: MutableList + var metrics: MutableSet + var score: Int? +} + +interface SessionStatusHistoryField : BaseField { + var session: SESSION + var status: SessionStatus + var timestamp: LocalDateTime +} + +interface SessionMetricField : BaseField { + var session: SESSION + var score: Int + var timestamp: LocalDateTime +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionRepository.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionRepository.kt new file mode 100644 index 0000000..0d26a1c --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionRepository.kt @@ -0,0 +1,9 @@ +package com.github.kusitms_bugi.domain.session.domain + +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session +import java.util.* + +interface SessionRepository { + fun findById(id: UUID): Session? + fun save(session: Session): Session +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionRepositoryImpl.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionRepositoryImpl.kt new file mode 100644 index 0000000..e22d8ec --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionRepositoryImpl.kt @@ -0,0 +1,32 @@ +package com.github.kusitms_bugi.domain.session.infrastructure + +import com.github.kusitms_bugi.domain.session.domain.SessionRepository +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionJpaRepository +import org.springframework.stereotype.Repository +import java.util.* + +@Repository +class SessionRepositoryImpl( + private val sessionJpaRepository: SessionJpaRepository +) : SessionRepository { + override fun findById(id: UUID): Session? { + return sessionJpaRepository.findById(id).orElse(null) + } + + override fun save(session: Session): Session { + return sessionJpaRepository.save(session) + } + + fun findByIdWithDetails(id: UUID): Session? { + return sessionJpaRepository.findByIdWithDetails(id).orElse(null) + } + + fun findByIdWithStatusHistory(id: UUID): Session? { + return sessionJpaRepository.findByIdWithStatusHistory(id).orElse(null) + } + + fun findByIdWithMetrics(id: UUID): Session? { + return sessionJpaRepository.findByIdWithMetrics(id).orElse(null) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/Session.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/Session.kt new file mode 100644 index 0000000..441af99 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/Session.kt @@ -0,0 +1,210 @@ +package com.github.kusitms_bugi.domain.session.infrastructure.jpa + +import com.github.kusitms_bugi.domain.session.domain.SessionField +import com.github.kusitms_bugi.domain.session.domain.SessionMetricField +import com.github.kusitms_bugi.domain.session.domain.SessionStatus +import com.github.kusitms_bugi.domain.session.domain.SessionStatusHistoryField +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import com.github.kusitms_bugi.global.entity.BaseEntity +import jakarta.persistence.* +import org.hibernate.annotations.BatchSize +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.Duration +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@Entity +@Table(name = "session") +class Session( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + override var user: User, + + @OrderBy("timestamp ASC") + @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) + @BatchSize(size = 25) + override var statusHistory: MutableList = mutableListOf(), + + @OrderBy("timestamp ASC") + @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) + @BatchSize(size = 25) + override var metrics: MutableSet = mutableSetOf(), + + @Column(name = "score", nullable = true) + override var score: Int? = null +) : BaseEntity(), SessionField { + + fun lastStatus(): SessionStatus? = statusHistory.lastOrNull()?.status + + private fun getActiveRanges(): List> { + val ranges = mutableListOf>() + var activeStartTime: LocalDateTime? = null + + statusHistory.forEach { history -> + when (history.status) { + SessionStatus.STARTED, SessionStatus.RESUMED -> activeStartTime = history.timestamp + SessionStatus.PAUSED, SessionStatus.STOPPED -> { + activeStartTime?.let { start -> + ranges.add(start to history.timestamp) + activeStartTime = null + } + } + } + } + + return ranges + } + + fun calculateTotalActiveSeconds(): Long { + return getActiveRanges().sumOf { (start, end) -> + ChronoUnit.SECONDS.between(start, end) + } + } + + fun calculatePausedDuration(start: LocalDateTime, end: LocalDateTime): Duration { + var pausedMillis = 0L + var pauseStartTime: LocalDateTime? = null + + statusHistory + .filter { it.timestamp in start..end } + .forEach { history -> + when (history.status) { + SessionStatus.PAUSED -> { + pauseStartTime = history.timestamp + } + SessionStatus.RESUMED -> { + pauseStartTime?.let { pauseStart -> + pausedMillis += ChronoUnit.MILLIS.between(pauseStart, history.timestamp) + pauseStartTime = null + } + } + else -> {} + } + } + + return Duration.ofMillis(pausedMillis) + } + + fun getActiveMetrics(): List { + val activeRanges = getActiveRanges() + return metrics + .filter { metric -> activeRanges.any { (start, end) -> metric.timestamp in start..end } } + .sortedBy { it.timestamp } + } + + fun getLevelDurations(): Map { + val activeMetrics = getActiveMetrics() + val levelDurations = (1..6).associateWith { 0L }.toMutableMap() + + // Pause 구간들을 한 번만 미리 계산 (O(m) where m = statusHistory 수) + val pauseRanges = getPauseRanges() + + activeMetrics.zipWithNext().forEach { (current, next) -> + val level = current.score.coerceIn(1, 6) + val duration = Duration.between(current.timestamp, next.timestamp) + + // 미리 계산된 pause 구간을 사용하여 pausedDuration 계산 (O(p) where p = pauseRanges 수) + val pausedDuration = calculatePausedDurationFromRanges(current.timestamp, next.timestamp, pauseRanges) + val activeDuration = duration.minus(pausedDuration).toMillis() + + if (activeDuration > 0) { + levelDurations[level] = levelDurations.getValue(level) + activeDuration + } + } + + return levelDurations + } + + private fun getPauseRanges(): List> { + val ranges = mutableListOf>() + var pauseStartTime: LocalDateTime? = null + + statusHistory.forEach { history -> + when (history.status) { + SessionStatus.PAUSED -> { + pauseStartTime = history.timestamp + } + SessionStatus.RESUMED -> { + pauseStartTime?.let { pauseStart -> + ranges.add(pauseStart to history.timestamp) + pauseStartTime = null + } + } + else -> {} + } + } + + return ranges + } + + private fun calculatePausedDurationFromRanges( + start: LocalDateTime, + end: LocalDateTime, + pauseRanges: List> + ): Duration { + var pausedMillis = 0L + + pauseRanges.forEach { (pauseStart, pauseEnd) -> + // pause 구간이 [start, end] 범위와 겹치는 부분 계산 + val overlapStart = maxOf(start, pauseStart) + val overlapEnd = minOf(end, pauseEnd) + + if (overlapStart < overlapEnd) { + pausedMillis += ChronoUnit.MILLIS.between(overlapStart, overlapEnd) + } + } + + return Duration.ofMillis(pausedMillis) + } + + fun calculateGoodSeconds(): Long { + val pauseRanges = getPauseRanges() + val activeMetrics = getActiveMetrics() + + val totalMillis = activeMetrics.zipWithNext() + .filter { (_, current) -> current.score <= 3 } + .sumOf { (prev, current) -> + val duration = ChronoUnit.MILLIS.between(prev.timestamp, current.timestamp) + val pausedDuration = calculatePausedDurationFromRanges(prev.timestamp, current.timestamp, pauseRanges).toMillis() + val result = duration - pausedDuration + println("prev=${prev.timestamp}, current=${current.timestamp}, score=${current.score}, duration=$duration, paused=$pausedDuration, result=$result") + result + } + + println("totalMillis=$totalMillis, goodSeconds=${totalMillis / 1_000}") + return totalMillis / 1_000 + } +} + +@Entity +@Table(name = "session_status") +@EntityListeners(AuditingEntityListener::class) +class SessionStatusHistory( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + override var session: Session, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + override var status: SessionStatus, + + @CreatedDate + @Column(nullable = false) + override var timestamp: LocalDateTime = LocalDateTime.now() +) : BaseEntity(), SessionStatusHistoryField + +@Entity +@Table(name = "session_metric") +@EntityListeners(AuditingEntityListener::class) +class SessionMetric( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + override var session: Session, + + @Column(nullable = false) + override var score: Int, + + @Column(nullable = false) + override var timestamp: LocalDateTime +) : BaseEntity(), SessionMetricField \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/SessionJpaRepository.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/SessionJpaRepository.kt new file mode 100644 index 0000000..1fbf2f1 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/SessionJpaRepository.kt @@ -0,0 +1,431 @@ +package com.github.kusitms_bugi.domain.session.infrastructure.jpa + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime +import java.util.* + +interface SessionJpaRepository : JpaRepository { + + @Query("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.id = :id") + fun findByIdWithDetails(@Param("id") id: UUID): Optional + + @Query("SELECT s FROM Session s LEFT JOIN FETCH s.statusHistory WHERE s.id = :id") + fun findByIdWithStatusHistory(@Param("id") id: UUID): Optional + + @Query("SELECT s FROM Session s LEFT JOIN FETCH s.metrics WHERE s.id = :id") + fun findByIdWithMetrics(@Param("id") id: UUID): Optional + + @Query( + value = """ + SELECT + TO_CHAR(s.created_at, 'YYYY-MM-DD') as date, + COALESCE(ROUND(AVG(s.score)), 0) as avg_score + FROM session s + WHERE s.user_id = :userId + AND s.created_at BETWEEN :startDate AND :endDate + AND s.deleted_at IS NULL + GROUP BY TO_CHAR(s.created_at, 'YYYY-MM-DD') + ORDER BY date + """, + nativeQuery = true + ) + fun getAverageScoresByDate( + @Param("userId") userId: UUID, + @Param("startDate") startDate: LocalDateTime, + @Param("endDate") endDate: LocalDateTime + ): List> + + @Query( + value = """ + WITH active_ranges AS ( + SELECT + s.id as session_id, + ss1.timestamp as start_time, + ss2.timestamp as end_time + FROM session s + CROSS JOIN LATERAL ( + SELECT timestamp, status, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('STARTED', 'RESUMED') + AND deleted_at IS NULL + ) ss1 + CROSS JOIN LATERAL ( + SELECT timestamp, status, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('PAUSED', 'STOPPED') + AND deleted_at IS NULL + AND timestamp > ss1.timestamp + ORDER BY timestamp + LIMIT 1 + ) ss2 + WHERE s.user_id = :userId + AND s.deleted_at IS NULL + AND ss2.rn >= ss1.rn + ), + active_metrics AS ( + SELECT + sm.session_id, + sm.score, + sm.timestamp, + LEAD(sm.timestamp) OVER (PARTITION BY sm.session_id ORDER BY sm.timestamp) as next_timestamp + FROM session_metric sm + WHERE sm.session_id IN (SELECT session_id FROM active_ranges) + AND sm.deleted_at IS NULL + AND EXISTS ( + SELECT 1 FROM active_ranges ar + WHERE ar.session_id = sm.session_id + AND sm.timestamp >= ar.start_time + AND sm.timestamp <= ar.end_time + ) + ) + SELECT + LEAST(GREATEST(score, 1), 6) as level, + COALESCE(SUM( + CASE + WHEN next_timestamp IS NOT NULL THEN + EXTRACT(EPOCH FROM (next_timestamp - timestamp)) * 1000 + ELSE 0 + END + ), 0) as duration_millis + FROM active_metrics + WHERE next_timestamp IS NOT NULL + GROUP BY LEAST(GREATEST(score, 1), 6) + ORDER BY level + """, + nativeQuery = true + ) + fun calculateLevelDurationsForUser(@Param("userId") userId: UUID): List> + + @Query( + value = """ + WITH active_ranges AS ( + SELECT + s.id as session_id, + ss1.timestamp as start_time, + ss2.timestamp as end_time + FROM session s + CROSS JOIN LATERAL ( + SELECT timestamp, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('STARTED', 'RESUMED') + AND deleted_at IS NULL + ) ss1 + CROSS JOIN LATERAL ( + SELECT timestamp, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('PAUSED', 'STOPPED') + AND deleted_at IS NULL + AND timestamp > ss1.timestamp + ORDER BY timestamp + LIMIT 1 + ) ss2 + WHERE s.user_id = :userId + AND s.created_at BETWEEN :startDate AND :endDate + AND s.deleted_at IS NULL + AND ss2.rn >= ss1.rn + ), + session_bad_durations AS ( + SELECT + ar.session_id, + SUM( + CASE + WHEN sm.score >= 4 AND next_sm.timestamp IS NOT NULL THEN + EXTRACT(EPOCH FROM (next_sm.timestamp - sm.timestamp)) * 1000 + ELSE 0 + END + ) as bad_duration_millis + FROM active_ranges ar + JOIN session_metric sm ON sm.session_id = ar.session_id + LEFT JOIN LATERAL ( + SELECT timestamp + FROM session_metric + WHERE session_id = ar.session_id + AND timestamp > sm.timestamp + AND deleted_at IS NULL + ORDER BY timestamp + LIMIT 1 + ) next_sm ON true + WHERE sm.deleted_at IS NULL + AND sm.timestamp >= ar.start_time + AND sm.timestamp <= ar.end_time + GROUP BY ar.session_id + ) + SELECT + COALESCE(ROUND(SUM(bad_duration_millis) / 60000.0 / COUNT(*)), 0) as avg_bad_minutes_per_session + FROM session_bad_durations + WHERE bad_duration_millis > 0 + """, + nativeQuery = true + ) + fun getHighlightStats( + @Param("userId") userId: UUID, + @Param("startDate") startDate: LocalDateTime, + @Param("endDate") endDate: LocalDateTime + ): Int? + + @Query( + value = """ + WITH active_ranges AS ( + SELECT + s.id as session_id, + s.score as session_score, + ss1.timestamp as start_time, + ss2.timestamp as end_time + FROM session s + CROSS JOIN LATERAL ( + SELECT timestamp, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('STARTED', 'RESUMED') + AND deleted_at IS NULL + ) ss1 + CROSS JOIN LATERAL ( + SELECT timestamp, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('PAUSED', 'STOPPED') + AND deleted_at IS NULL + AND timestamp > ss1.timestamp + ORDER BY timestamp + LIMIT 1 + ) ss2 + WHERE s.user_id = :userId + AND s.created_at > :createdAt + AND s.score IS NOT NULL + AND s.deleted_at IS NULL + AND ss2.rn >= ss1.rn + ), + session_level_durations AS ( + SELECT + ar.session_id, + ar.session_score, + SUM( + CASE + WHEN LEAST(GREATEST(sm.score, 1), 6) IN (1, 2) AND next_sm.timestamp IS NOT NULL THEN + EXTRACT(EPOCH FROM (next_sm.timestamp - sm.timestamp)) * 1000 + ELSE 0 + END + ) as good_duration_millis, + SUM( + CASE + WHEN next_sm.timestamp IS NOT NULL THEN + EXTRACT(EPOCH FROM (next_sm.timestamp - sm.timestamp)) * 1000 + ELSE 0 + END + ) as total_duration_millis + FROM active_ranges ar + JOIN session_metric sm ON sm.session_id = ar.session_id + LEFT JOIN LATERAL ( + SELECT timestamp + FROM session_metric + WHERE session_id = ar.session_id + AND timestamp > sm.timestamp + AND deleted_at IS NULL + ORDER BY timestamp + LIMIT 1 + ) next_sm ON true + WHERE sm.deleted_at IS NULL + AND sm.timestamp >= ar.start_time + AND sm.timestamp <= ar.end_time + GROUP BY ar.session_id, ar.session_score + ), + composite_scores AS ( + SELECT + CASE + WHEN total_duration_millis > 0 THEN + (session_score * 0.65) + ((good_duration_millis / total_duration_millis) * 100.0 * 0.35) + ELSE + session_score * 0.65 + 50.0 * 0.35 + END as composite_score + FROM session_level_durations + ) + SELECT COALESCE(ROUND(AVG(composite_score)), 50) as avg_composite_score + FROM composite_scores + """, + nativeQuery = true + ) + fun getAverageCompositeScore( + @Param("userId") userId: UUID, + @Param("createdAt") createdAt: LocalDateTime + ): Int? + + @Query( + value = """ + WITH active_ranges AS ( + SELECT + s.id as session_id, + s.created_at, + ss1.timestamp as start_time, + ss2.timestamp as end_time + FROM session s + CROSS JOIN LATERAL ( + SELECT timestamp, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('STARTED', 'RESUMED') + AND deleted_at IS NULL + ) ss1 + CROSS JOIN LATERAL ( + SELECT timestamp, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('PAUSED', 'STOPPED') + AND deleted_at IS NULL + AND timestamp > ss1.timestamp + ORDER BY timestamp + LIMIT 1 + ) ss2 + WHERE s.user_id = :userId + AND s.created_at BETWEEN :startDate AND :endDate + AND s.deleted_at IS NULL + AND ss2.rn >= ss1.rn + ), + active_metrics_with_recovery AS ( + SELECT + ar.session_id, + ar.created_at, + sm.score, + sm.timestamp, + EXTRACT(HOUR FROM sm.timestamp) as hour_of_day, + EXTRACT(DOW FROM ar.created_at) as day_of_week, + LEAD(sm.timestamp) OVER (PARTITION BY ar.session_id ORDER BY sm.timestamp) as next_timestamp, + LEAD(sm.score) OVER (PARTITION BY ar.session_id ORDER BY sm.timestamp) as next_score, + CASE + WHEN sm.score >= 4 AND + LAG(sm.score) OVER (PARTITION BY ar.session_id ORDER BY sm.timestamp) < 4 + THEN sm.timestamp + ELSE NULL + END as bad_posture_start + FROM active_ranges ar + JOIN session_metric sm ON sm.session_id = ar.session_id + WHERE sm.deleted_at IS NULL + AND sm.timestamp >= ar.start_time + AND sm.timestamp <= ar.end_time + ) + SELECT + CAST(hour_of_day AS INTEGER) as hour_of_day, + CAST(day_of_week AS INTEGER) as day_of_week, + COALESCE(SUM( + CASE + WHEN score >= 4 AND next_timestamp IS NOT NULL THEN + EXTRACT(EPOCH FROM (next_timestamp - timestamp)) * 1000 + ELSE 0 + END + ), 0) as bad_posture_duration_millis, + COALESCE(SUM( + CASE + WHEN score <= 3 AND next_timestamp IS NOT NULL THEN + EXTRACT(EPOCH FROM (next_timestamp - timestamp)) * 1000 + ELSE 0 + END + ), 0) as good_posture_duration_millis, + COUNT(CASE WHEN bad_posture_start IS NOT NULL AND next_score < 4 THEN 1 END) as recovery_count, + COALESCE(AVG( + CASE + WHEN bad_posture_start IS NOT NULL AND next_score < 4 THEN + EXTRACT(EPOCH FROM (next_timestamp - bad_posture_start)) * 1000 + ELSE NULL + END + ), 0) as avg_recovery_time_millis + FROM active_metrics_with_recovery + GROUP BY hour_of_day, day_of_week + ORDER BY hour_of_day, day_of_week + """, + nativeQuery = true + ) + fun getPosturePatternStats( + @Param("userId") userId: UUID, + @Param("startDate") startDate: LocalDateTime, + @Param("endDate") endDate: LocalDateTime + ): List> + + @Query( + value = """ + WITH active_ranges AS ( + SELECT + s.id as session_id, + s.created_at, + ss1.timestamp as start_time, + ss2.timestamp as end_time + FROM session s + CROSS JOIN LATERAL ( + SELECT timestamp, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('STARTED', 'RESUMED') + AND deleted_at IS NULL + ) ss1 + CROSS JOIN LATERAL ( + SELECT timestamp, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as rn + FROM session_status + WHERE session_id = s.id + AND status IN ('PAUSED', 'STOPPED') + AND deleted_at IS NULL + AND timestamp > ss1.timestamp + ORDER BY timestamp + LIMIT 1 + ) ss2 + WHERE s.user_id = :userId + AND s.created_at BETWEEN :startDate AND :endDate + AND s.deleted_at IS NULL + AND ss2.rn >= ss1.rn + ), + session_metrics AS ( + SELECT + ar.session_id, + ar.created_at, + LEAST(GREATEST(sm.score, 1), 6) as level, + EXTRACT(EPOCH FROM ( + LEAD(sm.timestamp) OVER (PARTITION BY ar.session_id ORDER BY sm.timestamp) - sm.timestamp + )) * 1000 as duration_millis + FROM active_ranges ar + JOIN session_metric sm ON sm.session_id = ar.session_id + WHERE sm.deleted_at IS NULL + AND sm.timestamp >= ar.start_time + AND sm.timestamp <= ar.end_time + ), + daily_stats AS ( + SELECT + DATE(created_at) as date, + SUM(CASE WHEN duration_millis IS NOT NULL THEN duration_millis / 1000.0 ELSE 0 END) / 60.0 as active_minutes, + SUM(CASE WHEN level >= 4 AND duration_millis IS NOT NULL THEN duration_millis ELSE 0 END) as bad_duration_millis, + SUM(CASE WHEN level <= 3 AND duration_millis IS NOT NULL THEN duration_millis ELSE 0 END) as good_duration_millis, + SUM(CASE WHEN duration_millis IS NOT NULL THEN duration_millis ELSE 0 END) as total_duration_millis, + AVG(CASE WHEN duration_millis IS NOT NULL THEN level ELSE NULL END) as avg_level + FROM session_metrics + GROUP BY DATE(created_at) + ) + SELECT + TO_CHAR(date, 'YYYY-MM-DD') as date, + COALESCE(ROUND(active_minutes), 0) as active_minutes, + COALESCE(bad_duration_millis, 0) as bad_duration_millis, + COALESCE(good_duration_millis, 0) as good_duration_millis, + COALESCE(total_duration_millis, 0) as total_duration_millis, + COALESCE(avg_level, 0) as avg_level + FROM daily_stats + ORDER BY date + """, + nativeQuery = true + ) + fun getAttendanceStats( + @Param("userId") userId: UUID, + @Param("startDate") startDate: LocalDateTime, + @Param("endDate") endDate: LocalDateTime + ): List> +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/security/SessionPermissionEvaluator.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/security/SessionPermissionEvaluator.kt new file mode 100644 index 0000000..c19da34 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/security/SessionPermissionEvaluator.kt @@ -0,0 +1,12 @@ +package com.github.kusitms_bugi.domain.session.infrastructure.security + +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session +import com.github.kusitms_bugi.global.security.CustomUserDetails +import org.springframework.stereotype.Component + +@Component +@Suppress("unused") // SpEL에서 사용 +class SessionPermissionEvaluator { + + fun canAccessSession(principal: CustomUserDetails, session: Session) = session.user.id == principal.getId() +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/SessionApi.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/SessionApi.kt new file mode 100644 index 0000000..07642ca --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/SessionApi.kt @@ -0,0 +1,62 @@ +package com.github.kusitms_bugi.domain.session.presentation + +import com.github.kusitms_bugi.domain.session.presentation.dto.request.MetricData +import com.github.kusitms_bugi.domain.session.presentation.dto.response.CreateSessionResponse +import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessionReportResponse +import com.github.kusitms_bugi.global.response.ApiResponse +import com.github.kusitms_bugi.global.security.CustomUserDetails +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* +import java.util.* + +@Tag(name = "세션") +@RequestMapping("/sessions") +interface SessionApi { + + @Operation(summary = "세션 조회") + @GetMapping("/{sessionId}/report") + fun getSessionReport( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable sessionId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "세션 생성") + @PostMapping + fun createSession( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "세션 일시정지") + @PatchMapping("/{sessionId}/pause") + fun pauseSession( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable sessionId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "세션 재개") + @PatchMapping("/{sessionId}/resume") + fun resumeSession( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable sessionId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "세션 중단") + @PatchMapping("/{sessionId}/stop") + fun stopSession( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable sessionId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "세션 메트릭 저장") + @PostMapping("/{sessionId}/metrics") + fun saveMetrics( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable sessionId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated @RequestBody request: List + ): ApiResponse +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/SessionController.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/SessionController.kt new file mode 100644 index 0000000..5a052bd --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/SessionController.kt @@ -0,0 +1,45 @@ +package com.github.kusitms_bugi.domain.session.presentation + +import com.github.kusitms_bugi.domain.session.application.SessionService +import com.github.kusitms_bugi.domain.session.presentation.dto.request.MetricData +import com.github.kusitms_bugi.domain.session.presentation.dto.response.CreateSessionResponse +import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessionReportResponse +import com.github.kusitms_bugi.global.response.ApiResponse +import com.github.kusitms_bugi.global.security.CustomUserDetails +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.RestController +import java.util.* + +@RestController +class SessionController( + private val sessionService: SessionService +) : SessionApi { + + override fun getSessionReport(sessionId: UUID, @AuthenticationPrincipal userDetails: CustomUserDetails): ApiResponse { + return ApiResponse.success(sessionService.getSession(sessionId, userDetails)) + } + + override fun createSession(userDetails: CustomUserDetails): ApiResponse { + return ApiResponse.success(sessionService.createSession(userDetails.user)) + } + + override fun pauseSession(sessionId: UUID, @AuthenticationPrincipal userDetails: CustomUserDetails): ApiResponse { + sessionService.pauseSession(sessionId, userDetails) + return ApiResponse.success() + } + + override fun resumeSession(sessionId: UUID, @AuthenticationPrincipal userDetails: CustomUserDetails): ApiResponse { + sessionService.resumeSession(sessionId, userDetails) + return ApiResponse.success() + } + + override fun stopSession(sessionId: UUID, @AuthenticationPrincipal userDetails: CustomUserDetails): ApiResponse { + sessionService.stopSession(sessionId, userDetails) + return ApiResponse.success() + } + + override fun saveMetrics(sessionId: UUID, @AuthenticationPrincipal userDetails: CustomUserDetails, request: List): ApiResponse { + sessionService.saveMetrics(sessionId, userDetails, request) + return ApiResponse.success() + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/request/SaveMetricsRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/request/SaveMetricsRequest.kt new file mode 100644 index 0000000..877efdf --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/request/SaveMetricsRequest.kt @@ -0,0 +1,12 @@ +package com.github.kusitms_bugi.domain.session.presentation.dto.request + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import java.time.LocalDateTime + +data class MetricData( + @field:Min(value = 1, message = "점수는 최소 1이어야 합니다.") + @field:Max(value = 6, message = "점수는 최대 6이어야 합니다.") + val score: Int, + val timestamp: LocalDateTime +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/CreateSessionResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/CreateSessionResponse.kt new file mode 100644 index 0000000..15b7766 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/CreateSessionResponse.kt @@ -0,0 +1,7 @@ +package com.github.kusitms_bugi.domain.session.presentation.dto.response + +import java.util.* + +data class CreateSessionResponse( + val sessionId: UUID +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionReportResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionReportResponse.kt new file mode 100644 index 0000000..6083b1b --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionReportResponse.kt @@ -0,0 +1,7 @@ +package com.github.kusitms_bugi.domain.session.presentation.dto.response + +data class GetSessionReportResponse( + val totalSeconds: Long, + val goodSeconds: Long, + val score: Int +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/User.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/User.kt new file mode 100644 index 0000000..a285edc --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/User.kt @@ -0,0 +1,12 @@ +package com.github.kusitms_bugi.domain.user.domain + +import com.github.kusitms_bugi.global.entity.BaseField + +interface UserField : BaseField { + val name: String + val email: String + val password: String + val avatar: String? + val active: Boolean + val sessions: MutableList +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserRepository.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserRepository.kt new file mode 100644 index 0000000..8771262 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserRepository.kt @@ -0,0 +1,10 @@ +package com.github.kusitms_bugi.domain.user.domain + +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import java.util.* + +interface UserRepository { + fun save(user: User): User + fun findById(id: UUID): User? + fun findByEmail(email: String): User? +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserRepositoryImpl.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserRepositoryImpl.kt new file mode 100644 index 0000000..d579355 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.github.kusitms_bugi.domain.user.infrastructure + +import com.github.kusitms_bugi.domain.user.domain.UserRepository +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.UserJpaRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Repository +import java.util.* + +@Repository +class UserRepositoryImpl( + private val userJpaRepository: UserJpaRepository +) : UserRepository { + override fun save(user: User): User { + return userJpaRepository.save(user) + } + + override fun findById(id: UUID): User? { + return userJpaRepository.findByIdOrNull(id) + } + + override fun findByEmail(email: String): User? { + return userJpaRepository.findByEmail(email) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/User.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/User.kt new file mode 100644 index 0000000..efb36ce --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/User.kt @@ -0,0 +1,29 @@ +package com.github.kusitms_bugi.domain.user.infrastructure.jpa + +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session +import com.github.kusitms_bugi.domain.user.domain.UserField +import com.github.kusitms_bugi.global.entity.BaseEntity +import jakarta.persistence.* + +@Entity +@Table(name = "users") +class User( + @Column(nullable = false) + override var name: String, + + @Column(nullable = false, unique = true) + override var email: String, + + @Column(nullable = false) + override var password: String, + + @Column + override var avatar: String? = null, + + @Column(nullable = false) + override var active: Boolean = false, + + @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true) + override var sessions: MutableList = mutableListOf() +) : BaseEntity(), UserField + diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/UserJpaRepository.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/UserJpaRepository.kt new file mode 100644 index 0000000..b8456c3 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/UserJpaRepository.kt @@ -0,0 +1,8 @@ +package com.github.kusitms_bugi.domain.user.infrastructure.jpa + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.* + +interface UserJpaRepository : JpaRepository { + fun findByEmail(email: String): User? +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserApi.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserApi.kt new file mode 100644 index 0000000..9af36c9 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserApi.kt @@ -0,0 +1,22 @@ +package com.github.kusitms_bugi.domain.user.presentation + +import com.github.kusitms_bugi.domain.user.presentation.dto.response.MyProfileResponse +import com.github.kusitms_bugi.global.response.ApiResponse +import com.github.kusitms_bugi.global.security.CustomUserDetails +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping + +@Tag(name = "유저") +@RequestMapping("/users") +interface UserApi { + + @Operation(summary = "내 정보 조회") + @GetMapping("/me") + fun getMyProfile( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserController.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserController.kt new file mode 100644 index 0000000..822675d --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserController.kt @@ -0,0 +1,15 @@ +package com.github.kusitms_bugi.domain.user.presentation + +import com.github.kusitms_bugi.domain.user.presentation.dto.response.MyProfileResponse +import com.github.kusitms_bugi.domain.user.presentation.dto.response.toMyProfileResponse +import com.github.kusitms_bugi.global.response.ApiResponse +import com.github.kusitms_bugi.global.security.CustomUserDetails +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController : UserApi { + + override fun getMyProfile(userDetails: CustomUserDetails): ApiResponse { + return ApiResponse.success(userDetails.user.toMyProfileResponse()) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/MyProfileResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/MyProfileResponse.kt new file mode 100644 index 0000000..cd19d6b --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/MyProfileResponse.kt @@ -0,0 +1,15 @@ +package com.github.kusitms_bugi.domain.user.presentation.dto.response + +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User + +data class MyProfileResponse( + val name: String, + val email: String, + val avatar: String? +) + +fun User.toMyProfileResponse() = MyProfileResponse( + name = this.name, + email = this.email, + avatar = this.avatar +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/config/ConfigurationPropertiesConfig.kt b/src/main/kotlin/com/github/kusitms_bugi/global/config/ConfigurationPropertiesConfig.kt new file mode 100644 index 0000000..8ac38e5 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/config/ConfigurationPropertiesConfig.kt @@ -0,0 +1,8 @@ +package com.github.kusitms_bugi.global.config + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationPropertiesScan("com.github.kusitms_bugi.global.properties") +class ConfigurationPropertiesConfig \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/config/JacksonConfig.kt b/src/main/kotlin/com/github/kusitms_bugi/global/config/JacksonConfig.kt new file mode 100644 index 0000000..9eef92a --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/config/JacksonConfig.kt @@ -0,0 +1,23 @@ +package com.github.kusitms_bugi.global.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JacksonConfig { + + @Bean + fun objectMapper(): ObjectMapper { + return jacksonObjectMapper() + .registerModules( + JavaTimeModule(), + KotlinModule.Builder().build() + ) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/config/JpaConfig.kt b/src/main/kotlin/com/github/kusitms_bugi/global/config/JpaConfig.kt new file mode 100644 index 0000000..18b9270 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/config/JpaConfig.kt @@ -0,0 +1,18 @@ +package com.github.kusitms_bugi.global.config + +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.FilterType +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import org.springframework.data.jpa.repository.config.EnableJpaRepositories + +@Configuration +@EnableJpaAuditing +@EnableJpaRepositories( + basePackages = ["com.github.kusitms_bugi.domain"], + includeFilters = [ComponentScan.Filter( + type = FilterType.REGEX, + pattern = [".*JpaRepository"] + )] +) +class JpaConfig \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/config/RedisConfig.kt b/src/main/kotlin/com/github/kusitms_bugi/global/config/RedisConfig.kt new file mode 100644 index 0000000..9a53110 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/config/RedisConfig.kt @@ -0,0 +1,33 @@ +package com.github.kusitms_bugi.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.FilterType +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +@EnableRedisRepositories( + basePackages = ["com.github.kusitms_bugi.domain"], + includeFilters = [ComponentScan.Filter( + type = FilterType.REGEX, + pattern = [".*RedisRepository"] + )] +) +class RedisConfig { + + @Bean + fun redisTemplate(connectionFactory: RedisConnectionFactory, jacksonConfig: JacksonConfig): RedisTemplate { + return RedisTemplate().apply { + setConnectionFactory(connectionFactory) + keySerializer = StringRedisSerializer() + valueSerializer = GenericJackson2JsonRedisSerializer(jacksonConfig.objectMapper()) + hashKeySerializer = StringRedisSerializer() + hashValueSerializer = GenericJackson2JsonRedisSerializer(jacksonConfig.objectMapper()) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/config/SecurityConfig.kt b/src/main/kotlin/com/github/kusitms_bugi/global/config/SecurityConfig.kt new file mode 100644 index 0000000..bc11fd0 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/config/SecurityConfig.kt @@ -0,0 +1,55 @@ +package com.github.kusitms_bugi.global.config + +import com.github.kusitms_bugi.global.properties.FrontendProperties +import com.github.kusitms_bugi.global.security.JwtAuthenticationFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.web.cors.CorsConfiguration + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +class SecurityConfig( + private val frontendProperties: FrontendProperties, + private val jwtAuthenticationFilter: JwtAuthenticationFilter +) { + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } + + @Bean + fun apiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .formLogin { it.disable() } + .httpBasic { it.disable() } + .cors { cors -> + cors.configurationSource { + val configuration = CorsConfiguration() + configuration.allowedOriginPatterns = frontendProperties.allowedOrigins + configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + configuration.allowedHeaders = listOf("*") + configuration.allowCredentials = true + configuration + } + } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { auth -> + auth + .requestMatchers("/sessions/**").authenticated() + .anyRequest().permitAll() + } + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) + + return http.build() + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/config/SwaggerConfig.kt b/src/main/kotlin/com/github/kusitms_bugi/global/config/SwaggerConfig.kt new file mode 100644 index 0000000..cab1134 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/config/SwaggerConfig.kt @@ -0,0 +1,40 @@ +package com.github.kusitms_bugi.global.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.boot.info.BuildProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SwaggerConfig( + private val buildProperties: BuildProperties +) { + + @Bean + fun openAPI(): OpenAPI { + val securitySchemeName = "Bearer Authentication" + + return OpenAPI() + .info( + Info() + .title("거부기린") + .version(buildProperties.version) + ) + .addSecurityItem(SecurityRequirement().addList(securitySchemeName)) + .components( + Components() + .addSecuritySchemes( + securitySchemeName, + SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ) + ) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/config/TimeZoneConfig.kt b/src/main/kotlin/com/github/kusitms_bugi/global/config/TimeZoneConfig.kt new file mode 100644 index 0000000..e8bb33d --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/config/TimeZoneConfig.kt @@ -0,0 +1,19 @@ +package com.github.kusitms_bugi.global.config + +import jakarta.annotation.PostConstruct +import org.springframework.context.annotation.Configuration +import java.util.* + +@Configuration +class TimeZoneConfig { + + @PostConstruct + fun configureTimeZone() { + TimeZone.setDefault(TimeZone.getTimeZone(SEOUL_TIMEZONE)) + System.setProperty("user.timezone", SEOUL_TIMEZONE) + } + + companion object { + private const val SEOUL_TIMEZONE = "Asia/Seoul" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/entity/BaseEntity.kt b/src/main/kotlin/com/github/kusitms_bugi/global/entity/BaseEntity.kt new file mode 100644 index 0000000..ed8731d --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/entity/BaseEntity.kt @@ -0,0 +1,43 @@ +package com.github.kusitms_bugi.global.entity + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.Id +import jakarta.persistence.MappedSuperclass +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime +import java.util.* + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class BaseEntity { + @Id + @Column(columnDefinition = "UUID") + val id: UUID = UUID.randomUUID() + + @CreatedDate + @Column(nullable = false, updatable = false) + var createdAt: LocalDateTime = LocalDateTime.now() + + @LastModifiedDate + @Column(nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() + + @Column + var deletedAt: LocalDateTime? = null + + fun delete() { + this.deletedAt = LocalDateTime.now() + } + + fun isDeleted(): Boolean = deletedAt != null +} + +interface BaseField { + val id: UUID + var createdAt: LocalDateTime + var updatedAt: LocalDateTime + var deletedAt: LocalDateTime? +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiException.kt b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiException.kt new file mode 100644 index 0000000..7a1446c --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiException.kt @@ -0,0 +1,22 @@ +package com.github.kusitms_bugi.global.exception + +class ApiException( + val code: String, + override val message: String, + override val cause: Throwable? = null +) : RuntimeException("[$code] $message", cause) { + + constructor(exceptionCode: ApiExceptionCode) : this(exceptionCode.code, exceptionCode.message, null) + + constructor(exceptionCode: ApiExceptionCode, cause: Throwable) : this( + exceptionCode.code, + exceptionCode.message, + cause + ) + + constructor(exceptionCode: ApiExceptionCode, message: String, cause: Throwable) : this( + exceptionCode.code, + message, + cause + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt new file mode 100644 index 0000000..76f6373 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt @@ -0,0 +1,57 @@ +package com.github.kusitms_bugi.global.exception + +interface ApiExceptionCode { + val code: String + val message: String +} + +enum class GlobalExceptionCode( + override val code: String, + override val message: String +) : ApiExceptionCode { + INTERNAL_SERVER_ERROR("GLOBAL-100", "서버 내부 오류가 발생했습니다."), + + BAD_REQUEST("GLOBAL-200", "잘못된 요청입니다."), + NOT_FOUND("GLOBAL-201", "요청한 리소스를 찾을 수 없습니다."), + METHOD_NOT_ALLOWED("GLOBAL-202", "지원하지 않는 HTTP 메서드입니다."), + UNSUPPORTED_MEDIA_TYPE("GLOBAL-203", "지원하지 않는 미디어 타입입니다."), + + VALIDATION_FAILED("GLOBAL-301", "입력값이 올바르지 않습니다"), + MISSING_PARAMETER("GLOBAL-302", "필수 요청 파라미터가 누락되었습니다."), + TYPE_MISMATCH("GLOBAL-303", "요청 파라미터 타입이 올바르지 않습니다."), + + NOT_AUTHENTICATED("GLOBAL-403", "권한이 없습니다."), +} + +enum class AuthExceptionCode( + override val code: String, + override val message: String +) : ApiExceptionCode { + INVALID_TOKEN("AUTH-100", "유효하지 않은 토큰입니다."), + TOKEN_EXPIRED("AUTH-101", "만료된 토큰입니다."), + INVALID_REFRESH_TOKEN("AUTH-102", "유효하지 않은 리프레시 토큰입니다."), +} + +enum class UserExceptionCode( + override val code: String, + override val message: String +) : ApiExceptionCode { + USER_NOT_FOUND("USER-100", "사용자를 찾을 수 없습니다."), + USER_NOT_ACTIVE("USER-101", "이메일 인증이 완료되지 않은 사용자입니다."), + + EMAIL_ALREADY_EXISTS("USER-201", "이미 존재하는 이메일입니다."), + USER_ALREADY_ACTIVE("USER-202", "이미 이메일 인증이 완료된 사용자입니다."), +} + +enum class SessionExceptionCode( + override val code: String, + override val message: String +) : ApiExceptionCode { + SESSION_NOT_FOUND("SESSION-100", "세션을 찾을 수 없습니다."), + SESSION_ACCESS_DENIED("SESSION-101", "세션에 접근할 권한이 없습니다."), + SESSION_ALREADY_STOPPED("SESSION-102", "이미 종료된 세션입니다."), + SESSION_NOT_STOPPED("SESSION-103", "종료되지 않은 세션입니다."), + SESSION_NOT_RESUMED("SESSION-104", "일시정지 상태가 아닌 세션입니다."), + SESSION_NOT_PAUSED("SESSION-105", "재개 상태가 아닌 세션입니다."), + SESSION_NOT_ACTIVE("SESSION-106", "활성 상태가 아닌 세션입니다."), +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/exception/ExceptionLoggingAspect.kt b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ExceptionLoggingAspect.kt new file mode 100644 index 0000000..4e3c85a --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ExceptionLoggingAspect.kt @@ -0,0 +1,21 @@ +package com.github.kusitms_bugi.global.exception + +import org.aspectj.lang.JoinPoint +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.annotation.Before +import org.slf4j.LoggerFactory +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component + +@Aspect +@Component +@Order(0) +class ExceptionLoggingAspect { + + private val logger = LoggerFactory.getLogger(ExceptionLoggingAspect::class.java) + + @Before("execution(* com.github.kusitms_bugi.global.exception.GlobalExceptionHandler.*(..)) && args(ex,..)") + fun logException(joinPoint: JoinPoint, ex: Exception) { + logger.error("[${joinPoint.signature.name}] ${ex.javaClass.simpleName} - ${ex.message}", ex) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/github/kusitms_bugi/global/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..6d5ca73 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/exception/GlobalExceptionHandler.kt @@ -0,0 +1,80 @@ +package com.github.kusitms_bugi.global.exception + +import com.github.kusitms_bugi.global.response.ApiResponse +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.security.authorization.AuthorizationDeniedException +import org.springframework.web.HttpMediaTypeException +import org.springframework.web.HttpRequestMethodNotSupportedException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingRequestValueException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.servlet.resource.NoResourceFoundException + +@RestControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(ApiException::class) + fun handleApiException(ex: ApiException): ApiResponse { + return ApiResponse.failure(ex) + } + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleMessageNotReadable(ex: HttpMessageNotReadableException): ApiResponse { + val message = ex.message?.let { + when { + it.contains("JSON property") && it.contains("due to missing") -> { + val propertyName = it.substringAfter("JSON property ").substringBefore(" due to missing") + "$propertyName 필드가 누락되었습니다." + } + else -> "요청 본문을 읽을 수 없습니다." + } + } ?: "요청 본문을 읽을 수 없습니다." + + return ApiResponse.failure(ApiException(GlobalExceptionCode.BAD_REQUEST, message, ex)) + } + + @ExceptionHandler(NoResourceFoundException::class) + fun handleNotFound(ex: NoResourceFoundException): ApiResponse { + return ApiResponse.failure(ApiException(GlobalExceptionCode.NOT_FOUND, ex)) + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) + fun handleMethodNotAllowed(ex: HttpRequestMethodNotSupportedException): ApiResponse { + return ApiResponse.failure(ApiException(GlobalExceptionCode.METHOD_NOT_ALLOWED, ex)) + } + + @ExceptionHandler(HttpMediaTypeException::class) + fun handleMediaTypeNotSupported(ex: HttpMediaTypeException): ApiResponse { + return ApiResponse.failure(ApiException(GlobalExceptionCode.UNSUPPORTED_MEDIA_TYPE, ex)) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidation(ex: MethodArgumentNotValidException): ApiResponse { + val errors = ex.bindingResult.fieldErrors + .joinToString(", ") { "${it.field}: ${it.defaultMessage}" } + + return ApiResponse.failure(ApiException(GlobalExceptionCode.VALIDATION_FAILED, errors, ex)) + } + + @ExceptionHandler(MissingRequestValueException::class) + fun handleMissingParameter(ex: MissingRequestValueException): ApiResponse { + return ApiResponse.failure(ApiException(GlobalExceptionCode.MISSING_PARAMETER, ex)) + } + + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handleTypeMismatch(ex: MethodArgumentTypeMismatchException): ApiResponse { + return ApiResponse.failure(ApiException(GlobalExceptionCode.TYPE_MISMATCH, ex)) + } + + @ExceptionHandler(AuthorizationDeniedException::class) + fun authorizationDenied(ex: AuthorizationDeniedException): ApiResponse { + return ApiResponse.failure(ApiException(GlobalExceptionCode.NOT_AUTHENTICATED, ex)) + } + + @ExceptionHandler(Exception::class) + fun handleGenericException(ex: Exception): ApiResponse { + return ApiResponse.failure(ApiException(GlobalExceptionCode.INTERNAL_SERVER_ERROR, ex)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/filter/ArrayParameterFilter.kt b/src/main/kotlin/com/github/kusitms_bugi/global/filter/ArrayParameterFilter.kt new file mode 100644 index 0000000..77476eb --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/filter/ArrayParameterFilter.kt @@ -0,0 +1,40 @@ +package com.github.kusitms_bugi.global.filter + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper +import jakarta.servlet.http.HttpServletResponse +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +class ArrayParameterFilter : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val wrappedRequest = object : HttpServletRequestWrapper(request) { + override fun getParameterMap(): Map> { + val originalParams = super.getParameterMap() + val modifiedParams = mutableMapOf>() + + originalParams.forEach { (key, values) -> + val cleanKey = key.replace("[]", "") + modifiedParams[cleanKey] = values + } + + return modifiedParams + } + + override fun getParameterValues(name: String): Array? { + return parameterMap[name] + } + } + + filterChain.doFilter(wrappedRequest, response) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/filter/RequestLoggingFilter.kt b/src/main/kotlin/com/github/kusitms_bugi/global/filter/RequestLoggingFilter.kt new file mode 100644 index 0000000..8248fcb --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/filter/RequestLoggingFilter.kt @@ -0,0 +1,82 @@ +package com.github.kusitms_bugi.global.filter + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Configuration +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingRequestWrapper +import org.springframework.web.util.ContentCachingResponseWrapper + +@Configuration +class RequestLoggingFilter : OncePerRequestFilter() { + + private val _logger = LoggerFactory.getLogger(RequestLoggingFilter::class.java) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val requestWrapper = ContentCachingRequestWrapper(request) + val responseWrapper = ContentCachingResponseWrapper(response) + + val startTime = System.currentTimeMillis() + + try { + filterChain.doFilter(requestWrapper, responseWrapper) + } finally { + val duration = System.currentTimeMillis() - startTime + logRequestAndResponse(requestWrapper, responseWrapper, duration) + responseWrapper.copyBodyToResponse() + } + } + + private fun logRequestAndResponse( + request: ContentCachingRequestWrapper, + response: ContentCachingResponseWrapper, + duration: Long + ) { + val method = request.method + val uri = request.requestURI + val queryString = request.queryString?.let { "?$it" } ?: "" + val status = response.status + val clientIp = getClientIp(request) + + val requestBody = getRequestBody(request) + val responseBody = getResponseBody(response) + + val logMessage = "HTTP Request/Response - $method $uri$queryString - Status: $status - Duration: ${duration}ms - IP: $clientIp - Request Body: $requestBody - Response Body: $responseBody" + _logger.info(logMessage) + } + + private fun getRequestBody(request: ContentCachingRequestWrapper): String { + val content = request.contentAsByteArray + return if (content.isNotEmpty()) { + String(content, Charsets.UTF_8).take(1000) + } else { + "Empty" + } + } + + private fun getResponseBody(response: ContentCachingResponseWrapper): String { + val content = response.contentAsByteArray + return if (content.isNotEmpty()) { + String(content, Charsets.UTF_8).take(1000) + } else { + "Empty" + } + } + + private fun getClientIp(request: HttpServletRequest): String { + val xForwardedFor = request.getHeader("X-Forwarded-For") + val xRealIp = request.getHeader("X-Real-IP") + + return when { + !xForwardedFor.isNullOrBlank() -> xForwardedFor.split(",")[0].trim() + !xRealIp.isNullOrBlank() -> xRealIp + else -> request.remoteAddr + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/mail/EmailService.kt b/src/main/kotlin/com/github/kusitms_bugi/global/mail/EmailService.kt new file mode 100644 index 0000000..f9618a3 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/mail/EmailService.kt @@ -0,0 +1,36 @@ +package com.github.kusitms_bugi.global.mail + +import com.github.kusitms_bugi.global.properties.MailProperties +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.mail.javamail.MimeMessageHelper +import org.springframework.stereotype.Service +import org.thymeleaf.TemplateEngine +import org.thymeleaf.context.Context + +@Service +class EmailService( + private val mailSender: JavaMailSender, + private val mailProperties: MailProperties, + private val templateEngine: TemplateEngine +) { + + fun sendVerificationEmail(email: String, token: String, callbackUrl: String) { + val verificationUrl = "$callbackUrl?token=$token" + val subject = "이메일 인증을 완료해주세요" + + val context = Context() + context.setVariable("verificationUrl", verificationUrl) + val content = templateEngine.process("email-verification", context) + + val message = mailSender.createMimeMessage() + val helper = MimeMessageHelper(message, true, "UTF-8") + + helper.setFrom(mailProperties.from) + helper.setTo(email) + helper.setSubject(subject) + helper.setText(content, true) + + mailSender.send(message) + } + +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/properties/FrontendProperties.kt b/src/main/kotlin/com/github/kusitms_bugi/global/properties/FrontendProperties.kt new file mode 100644 index 0000000..076c144 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/properties/FrontendProperties.kt @@ -0,0 +1,10 @@ +package com.github.kusitms_bugi.global.properties + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "frontend") +data class FrontendProperties( + val allowedOrigins: List +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/properties/JwtProperties.kt b/src/main/kotlin/com/github/kusitms_bugi/global/properties/JwtProperties.kt new file mode 100644 index 0000000..df313d5 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/properties/JwtProperties.kt @@ -0,0 +1,16 @@ +package com.github.kusitms_bugi.global.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated +import java.time.Duration + +@Validated +@ConfigurationProperties(prefix = "jwt") +data class JwtProperties( + @field:NotBlank + val secret: String, + + val accessTokenValidity: Duration, + val refreshTokenValidity: Duration +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/properties/MailProperties.kt b/src/main/kotlin/com/github/kusitms_bugi/global/properties/MailProperties.kt new file mode 100644 index 0000000..3e6486f --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/properties/MailProperties.kt @@ -0,0 +1,10 @@ +package com.github.kusitms_bugi.global.properties + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "mail") +data class MailProperties( + val from: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/response/ApiResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/global/response/ApiResponse.kt new file mode 100644 index 0000000..826db1b --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/response/ApiResponse.kt @@ -0,0 +1,68 @@ +package com.github.kusitms_bugi.global.response + +import com.github.kusitms_bugi.global.exception.ApiException +import java.time.LocalDateTime + +data class ApiResponse( + val timestamp: LocalDateTime, + val success: Boolean, + val data: T? = null, + val code: String? = null, + val message: String? = null +) { + companion object { + fun success(data: T): ApiResponse { + return ApiResponse( + timestamp = LocalDateTime.now(), + success = true, + data = data, + code = "SUCCESS", + message = null + ) + } + + fun success(): ApiResponse { + return ApiResponse( + timestamp = LocalDateTime.now(), + success = true, + data = Unit, + code = "SUCCESS", + message = null + ) + } + + fun failure(exception: ApiException): ApiResponse { + return ApiResponse( + timestamp = LocalDateTime.now(), + success = false, + data = null, + code = exception.code, + message = exception.message + ) + } + } +} + +data class Page ( + val content: List, + val page: Int, + val size: Int, + val totalElements: Long, + val totalPages: Int, + val last: Boolean +) { + companion object { + fun of(content: List, page: Int, size: Int, totalElements: Long): Page { + val totalPages = ((totalElements + size - 1) / size).toInt().coerceAtLeast(1) + + return Page( + content = content, + page = page, + size = size, + totalElements = totalElements, + totalPages = totalPages, + last = page >= totalPages - 1 + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetails.kt b/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetails.kt new file mode 100644 index 0000000..b72be70 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetails.kt @@ -0,0 +1,29 @@ +package com.github.kusitms_bugi.global.security + +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import java.util.* + +class CustomUserDetails( + val user: User +) : UserDetails { + fun getId(): UUID = user.id + + override fun getAuthorities(): Collection { + return listOf(SimpleGrantedAuthority("ROLE_USER")) + } + + override fun getPassword(): String = user.password + + override fun getUsername(): String = user.email + + override fun isAccountNonExpired(): Boolean = true + + override fun isAccountNonLocked(): Boolean = true + + override fun isCredentialsNonExpired(): Boolean = true + + override fun isEnabled(): Boolean = user.deletedAt == null +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetailsService.kt b/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetailsService.kt new file mode 100644 index 0000000..d747e44 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetailsService.kt @@ -0,0 +1,23 @@ +package com.github.kusitms_bugi.global.security + +import com.github.kusitms_bugi.domain.user.domain.UserRepository +import com.github.kusitms_bugi.global.exception.ApiException +import com.github.kusitms_bugi.global.exception.UserExceptionCode +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service +import java.util.* + +@Service +class CustomUserDetailsService( + private val userRepository: UserRepository +) : UserDetailsService { + override fun loadUserByUsername(id: String): UserDetails { + return CustomUserDetails( + userRepository.findById(UUID.fromString(id)) + ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + ) + } + + fun loadUserByUsername(id: UUID) = loadUserByUsername(id.toString()) +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/security/EmailVerificationTokenProvider.kt b/src/main/kotlin/com/github/kusitms_bugi/global/security/EmailVerificationTokenProvider.kt new file mode 100644 index 0000000..e5c9ee8 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/EmailVerificationTokenProvider.kt @@ -0,0 +1,53 @@ +package com.github.kusitms_bugi.global.security + +import com.github.kusitms_bugi.global.properties.JwtProperties +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.util.* +import javax.crypto.SecretKey + +@Component +class EmailVerificationTokenProvider( + private val jwtProperties: JwtProperties +) { + private val key: SecretKey by lazy { + Keys.hmacShaKeyFor(jwtProperties.secret.toByteArray()) + } + + fun generateEmailVerificationToken(userId: UUID): String { + val now = Date() + + return Jwts.builder() + .subject(userId.toString()) + .issuer("bugi-core") + .audience().add("bugi-client").and() + .issuedAt(now) + .notBefore(Date(now.time)) + .signWith(key) + .compact() + } + + fun validateToken(token: String): Boolean { + return try { + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + true + } catch (_: Exception) { + false + } + } + + fun getUserIdFromToken(token: String): UUID { + return UUID.fromString( + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .payload + .subject + ) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..ab3557d --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtAuthenticationFilter.kt @@ -0,0 +1,50 @@ +package com.github.kusitms_bugi.global.security + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.kusitms_bugi.global.exception.ApiException +import com.github.kusitms_bugi.global.response.ApiResponse +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class JwtAuthenticationFilter( + private val jwtTokenProvider: JwtTokenProvider, + private val userDetailsService: CustomUserDetailsService, + private val objectMapper: ObjectMapper +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + try { + extractTokenFromRequest(request) + ?.takeIf { jwtTokenProvider.validateToken(it) } + ?.let { jwtTokenProvider.getUserIdFromToken(it) } + ?.let { userDetailsService.loadUserByUsername(it) } + ?.let { UsernamePasswordAuthenticationToken(it, null, it.authorities) } + ?.let { SecurityContextHolder.getContext().authentication = it } + + filterChain.doFilter(request, response) + } catch (e: ApiException) { + response.status = HttpServletResponse.SC_OK + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = "UTF-8" + + response.writer.write(objectMapper.writeValueAsString(ApiResponse.failure(e))) + } + } + + private fun extractTokenFromRequest(request: HttpServletRequest): String? { + return request.getHeader("Authorization") + ?.takeIf { it.startsWith("Bearer ") } + ?.substring(7) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtTokenProvider.kt b/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtTokenProvider.kt new file mode 100644 index 0000000..0296268 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtTokenProvider.kt @@ -0,0 +1,94 @@ +package com.github.kusitms_bugi.global.security + +import com.github.kusitms_bugi.global.exception.ApiException +import com.github.kusitms_bugi.global.exception.AuthExceptionCode +import com.github.kusitms_bugi.global.properties.JwtProperties +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.util.* +import java.util.concurrent.TimeUnit +import javax.crypto.SecretKey + +@Component +class JwtTokenProvider( + private val jwtProperties: JwtProperties, + private val redisTemplate: RedisTemplate +) { + private val key: SecretKey by lazy { + Keys.hmacShaKeyFor(jwtProperties.secret.toByteArray()) + } + + fun generateAccessToken(userId: UUID): String { + return createToken(userId, jwtProperties.accessTokenValidity.toMillis(), TokenType.ACCESS) + } + + fun generateRefreshToken(userId: UUID): String { + val token = createToken(userId, jwtProperties.refreshTokenValidity.toMillis(), TokenType.REFRESH) + + redisTemplate.opsForValue().set( + "refresh_token:$userId", + token, + jwtProperties.refreshTokenValidity.toMillis(), + TimeUnit.MILLISECONDS + ) + + return token + } + + fun validateRefreshToken(refreshToken: String): Boolean { + if (!validateToken(refreshToken)) { + return false + } + val userId = getUserIdFromToken(refreshToken) + val key = "refresh_token:$userId" + val storedToken = redisTemplate.opsForValue().get(key) + return storedToken == refreshToken + } + + private fun createToken(userId: UUID, validity: Long, tokenType: TokenType): String { + val now = Date() + + return Jwts.builder() + .subject(userId.toString()) + .issuer("bugi-core") + .audience().add("bugi-client").and() + .issuedAt(now) + .notBefore(Date(now.time)) + .expiration(Date(now.time + validity)) + .claim("type", tokenType.name) + .signWith(key) + .compact() + } + + fun validateToken(token: String): Boolean { + return try { + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + true + } catch (_: ExpiredJwtException) { + throw ApiException(AuthExceptionCode.TOKEN_EXPIRED) + } catch (_: Exception) { + false + } + } + + fun getUserIdFromToken(token: String): UUID { + return UUID.fromString( + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .payload + .subject + ) + } + + fun getUserIdFromRefreshToken(refreshToken: String): UUID { + return getUserIdFromToken(refreshToken) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/security/TokenType.kt b/src/main/kotlin/com/github/kusitms_bugi/global/security/TokenType.kt new file mode 100644 index 0000000..d48fa66 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/TokenType.kt @@ -0,0 +1,6 @@ +package com.github.kusitms_bugi.global.security + +enum class TokenType { + ACCESS, + REFRESH +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/validator/AllowedOrigin.kt b/src/main/kotlin/com/github/kusitms_bugi/global/validator/AllowedOrigin.kt new file mode 100644 index 0000000..b75eca5 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/validator/AllowedOrigin.kt @@ -0,0 +1,14 @@ +package com.github.kusitms_bugi.global.validator + +import jakarta.validation.Constraint +import jakarta.validation.Payload +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [AllowedOriginValidator::class]) +annotation class AllowedOrigin( + val message: String = "허용되지 않은 콜백 URL입니다.", + val groups: Array> = [], + val payload: Array> = [] +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/global/validator/AllowedOriginValidator.kt b/src/main/kotlin/com/github/kusitms_bugi/global/validator/AllowedOriginValidator.kt new file mode 100644 index 0000000..06906ea --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/validator/AllowedOriginValidator.kt @@ -0,0 +1,20 @@ +package com.github.kusitms_bugi.global.validator + +import com.github.kusitms_bugi.global.properties.FrontendProperties +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import org.springframework.stereotype.Component + +@Component +class AllowedOriginValidator( + private val frontendProperties: FrontendProperties +) : ConstraintValidator { + + override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean { + if (value.isNullOrBlank()) return false + + return frontendProperties.allowedOrigins.any { origin -> + value.startsWith(origin) + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..a82c083 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,12 @@ +spring: + profiles: + include: + - actuator + - database + - flyway + - frontend + - jwt + - mail + - redis + - server + - swagger \ No newline at end of file diff --git a/src/main/resources/config/application-actuator.yaml b/src/main/resources/config/application-actuator.yaml new file mode 100644 index 0000000..0b31eed --- /dev/null +++ b/src/main/resources/config/application-actuator.yaml @@ -0,0 +1,8 @@ +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: when-authorized \ No newline at end of file diff --git a/src/main/resources/config/application-database.yaml b/src/main/resources/config/application-database.yaml new file mode 100644 index 0000000..206a8c0 --- /dev/null +++ b/src/main/resources/config/application-database.yaml @@ -0,0 +1,24 @@ +spring: + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:bugi}?currentSchema=${DB_SCHEMA:core}&serverTimezone=Asia/Seoul + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:root} + driver-class-name: org.postgresql.Driver + jpa: + show-sql: true + open-in-view: false + hibernate: + ddl-auto: validate + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul + +--- +spring: + config: + activate: + on-profile: + - deployment + jpa: + show-sql: false \ No newline at end of file diff --git a/src/main/resources/config/application-flyway.yaml b/src/main/resources/config/application-flyway.yaml new file mode 100644 index 0000000..52840f2 --- /dev/null +++ b/src/main/resources/config/application-flyway.yaml @@ -0,0 +1,5 @@ +spring: + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true \ No newline at end of file diff --git a/src/main/resources/config/application-frontend.yaml b/src/main/resources/config/application-frontend.yaml new file mode 100644 index 0000000..3f33dc6 --- /dev/null +++ b/src/main/resources/config/application-frontend.yaml @@ -0,0 +1,2 @@ +frontend: + allowed-origins: ${ALLOWED_ORIGINS} diff --git a/src/main/resources/config/application-jwt.yaml b/src/main/resources/config/application-jwt.yaml new file mode 100644 index 0000000..f2c0e50 --- /dev/null +++ b/src/main/resources/config/application-jwt.yaml @@ -0,0 +1,4 @@ +jwt: + secret: ${JWT_SECRET} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1h} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:30d} diff --git a/src/main/resources/config/application-mail.yaml b/src/main/resources/config/application-mail.yaml new file mode 100644 index 0000000..a92574c --- /dev/null +++ b/src/main/resources/config/application-mail.yaml @@ -0,0 +1,19 @@ +spring: + mail: + host: ${MAIL_HOST} + port: ${MAIL_PORT} + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + ssl: + enable: true + required: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + +mail: + from: ${MAIL_USERNAME} diff --git a/src/main/resources/config/application-redis.yaml b/src/main/resources/config/application-redis.yaml new file mode 100644 index 0000000..7edd0b1 --- /dev/null +++ b/src/main/resources/config/application-redis.yaml @@ -0,0 +1,10 @@ +spring: + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD} + timeout: 5000ms + connect-timeout: 30000ms + ssl: + enabled: ${REDIS_SSL_ENABLED:false} \ No newline at end of file diff --git a/src/main/resources/config/application-server.yaml b/src/main/resources/config/application-server.yaml new file mode 100644 index 0000000..8388edf --- /dev/null +++ b/src/main/resources/config/application-server.yaml @@ -0,0 +1,7 @@ +server: + forward-headers-strategy: native + servlet: + encoding: + charset: UTF-8 + enabled: true + force: true \ No newline at end of file diff --git a/src/main/resources/config/application-swagger.yaml b/src/main/resources/config/application-swagger.yaml new file mode 100644 index 0000000..24bb65b --- /dev/null +++ b/src/main/resources/config/application-swagger.yaml @@ -0,0 +1,16 @@ +springdoc: + api-docs: + enabled: true + path: /swagger/configuration + swagger-ui: + enabled: true + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + display-request-duration: true + disable-swagger-default-url: true + default-models-expand-depth: -1 + persist-authorization: true + show-actuator: true + default-consumes-media-type: application/json + default-produces-media-type: application/json diff --git a/src/main/resources/db/migration/V1__create_users_table.sql b/src/main/resources/db/migration/V1__create_users_table.sql new file mode 100644 index 0000000..3e7e83d --- /dev/null +++ b/src/main/resources/db/migration/V1__create_users_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + avatar VARCHAR(500) +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_deleted_at ON users(deleted_at); diff --git a/src/main/resources/db/migration/V2__add_active_column_to_users.sql b/src/main/resources/db/migration/V2__add_active_column_to_users.sql new file mode 100644 index 0000000..fadbb1c --- /dev/null +++ b/src/main/resources/db/migration/V2__add_active_column_to_users.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN active BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/main/resources/db/migration/V3__create_session_tables.sql b/src/main/resources/db/migration/V3__create_session_tables.sql new file mode 100644 index 0000000..48d4d28 --- /dev/null +++ b/src/main/resources/db/migration/V3__create_session_tables.sql @@ -0,0 +1,25 @@ +CREATE TABLE session ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + user_id UUID NOT NULL, + CONSTRAINT fk_session_user FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE session_status ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + session_id UUID NOT NULL, + status VARCHAR(50) NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_session_status_session FOREIGN KEY (session_id) REFERENCES session(id) +); + +CREATE INDEX idx_session_user_id ON session(user_id); +CREATE INDEX idx_session_deleted_at ON session(deleted_at); +CREATE INDEX idx_session_status_session_id ON session_status(session_id); +CREATE INDEX idx_session_status_timestamp ON session_status(timestamp); +CREATE INDEX idx_session_status_deleted_at ON session_status(deleted_at); diff --git a/src/main/resources/db/migration/V4__create_session_metric_table.sql b/src/main/resources/db/migration/V4__create_session_metric_table.sql new file mode 100644 index 0000000..babdb54 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_session_metric_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE session_metric ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + session_id UUID NOT NULL, + score DOUBLE PRECISION NOT NULL, + timestamp TIMESTAMP NOT NULL, + CONSTRAINT fk_session_metric_session FOREIGN KEY (session_id) REFERENCES session(id) +); + +CREATE INDEX idx_session_metric_session_id ON session_metric(session_id); +CREATE INDEX idx_session_metric_timestamp ON session_metric(timestamp); +CREATE INDEX idx_session_metric_deleted_at ON session_metric(deleted_at); diff --git a/src/main/resources/db/migration/V5__add_score_to_session.sql b/src/main/resources/db/migration/V5__add_score_to_session.sql new file mode 100644 index 0000000..3037940 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_score_to_session.sql @@ -0,0 +1 @@ +ALTER TABLE session ADD COLUMN score INTEGER; diff --git a/src/main/resources/db/migration/V6__change_score_to_int.sql b/src/main/resources/db/migration/V6__change_score_to_int.sql new file mode 100644 index 0000000..1faf323 --- /dev/null +++ b/src/main/resources/db/migration/V6__change_score_to_int.sql @@ -0,0 +1 @@ +ALTER TABLE session_metric ALTER COLUMN score TYPE INTEGER; diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html new file mode 100644 index 0000000..a9fc83d --- /dev/null +++ b/src/main/resources/templates/email-verification.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+

+ 메일 인증 안내 +

+
+ + + + + + +
+

+ 거부기린과 함께 서비스를 시작할 준비 되셨나요?
+ 아래 메일 인증 버튼을 클릭하여 회원가입을 완료해주세요. +

+
+ + + + + + +
+ + 메일 인증 + +
+ + + + + + +
+
+
+ + + + + + +
+

+ 만약 버튼이 정상적으로 클릭되지 않는다면 아래 링크를 복사하여 접속해주세요
+ https://example.com/verify?token=sample +

+
+
+
+
+ + \ No newline at end of file