From e6f8c989b98c52e2bc362fdbad3e3f790a553763 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Mon, 27 Oct 2025 04:03:46 +0900 Subject: [PATCH 01/29] =?UTF-8?q?Feature/=EC=B4=88=EA=B8=B0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: git 설정 추가 * build: Gradle 빌드 설정 * feat: Spring Boot 애플리케이션 초기 설정 * config: 데이터베이스 및 Redis 설정 * config: 서버 및 Actuator 설정 * config: Swagger API 문서 설정 * config: JPA, Redis, Jackson 설정 * config: Properties 설정 추가 * feat: API 응답 포맷 구현 * feat: 전역 예외 처리 구현 * feat: HTTP 필터 구현 * feat: 커스텀 유효성 검증기 구현 * feat: JPA 기본 엔티티 구현 * config: JWT 및 프론트엔드 연동 설정 * feat: JWT 토큰 발급 및 검증 구현 * feat: Spring Security UserDetails 구현 * feat: JWT 인증 필터 구현 * feat: Spring Security 설정 구현 * feat: 이메일 인증 토큰 발급 구현 * feat: 이메일 발송 기능 구현 * feat: 이메일 템플릿 추가 * feat: 사용자 도메인 엔티티 구현 * feat: 사용자 API DTO 구현 * feat: 사용자 인증 비즈니스 로직 구현 * feat: 사용자 인프라 레이어 구현 * feat: 사용자 인증 컨트롤러 구현 * feat: 데이터베이스 마이그레이션 스크립트 추가 --- .env.template | 27 ++ .gitignore | 44 +++ build.gradle.kts | 71 +++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ .../com/github/kusitms_bugi/Application.kt | 11 + .../domain/user/application/UserService.kt | 92 +++++++ .../kusitms_bugi/domain/user/domain/User.kt | 25 ++ .../domain/user/domain/UserApi.kt | 43 +++ .../domain/user/domain/UserRepository.kt | 10 + .../user/infrastructure/UserJpaRepository.kt | 9 + .../user/infrastructure/UserRepositoryImpl.kt | 28 ++ .../user/presentation/UserController.kt | 43 +++ .../dto/request/CheckEmailDuplicateRequest.kt | 10 + .../presentation/dto/request/LoginRequest.kt | 13 + .../dto/request/RefreshTokenRequest.kt | 8 + .../request/ResendVerificationEmailRequest.kt | 15 ++ .../presentation/dto/request/SignupRequest.kt | 34 +++ .../dto/request/VerifyEmailRequest.kt | 8 + .../response/CheckEmailDuplicateResponse.kt | 5 + .../dto/response/LoginResponse.kt | 6 + .../dto/response/RefreshTokenResponse.kt | 6 + .../dto/response/SignupResponse.kt | 18 ++ .../config/ConfigurationPropertiesConfig.kt | 8 + .../global/config/JacksonConfig.kt | 23 ++ .../kusitms_bugi/global/config/JpaConfig.kt | 18 ++ .../kusitms_bugi/global/config/RedisConfig.kt | 33 +++ .../global/config/SecurityConfig.kt | 47 ++++ .../global/config/SwaggerConfig.kt | 40 +++ .../global/config/TimeZoneConfig.kt | 19 ++ .../kusitms_bugi/global/entity/BaseEntity.kt | 36 +++ .../global/exception/ApiException.kt | 22 ++ .../global/exception/ApiExceptionCode.kt | 38 +++ .../exception/ExceptionLoggingAspect.kt | 21 ++ .../exception/GlobalExceptionHandler.kt | 80 ++++++ .../global/filter/ArrayParameterFilter.kt | 40 +++ .../global/filter/RequestLoggingFilter.kt | 84 ++++++ .../kusitms_bugi/global/mail/EmailService.kt | 36 +++ .../global/properties/FrontendProperties.kt | 10 + .../global/properties/JwtProperties.kt | 16 ++ .../global/properties/MailProperties.kt | 10 + .../global/response/ApiResponse.kt | 68 +++++ .../global/security/CustomUserDetails.kt | 29 ++ .../security/CustomUserDetailsService.kt | 23 ++ .../EmailVerificationTokenProvider.kt | 53 ++++ .../security/JwtAuthenticationFilter.kt | 37 +++ .../global/security/JwtTokenProvider.kt | 94 +++++++ .../kusitms_bugi/global/security/TokenType.kt | 6 + .../global/validator/AllowedOrigin.kt | 14 + .../validator/AllowedOriginValidator.kt | 20 ++ src/main/resources/application.yaml | 12 + .../config/application-actuator.yaml | 8 + .../config/application-database.yaml | 24 ++ .../resources/config/application-flyway.yaml | 5 + .../config/application-frontend.yaml | 2 + .../resources/config/application-jwt.yaml | 4 + .../resources/config/application-mail.yaml | 19 ++ .../resources/config/application-redis.yaml | 10 + .../resources/config/application-server.yaml | 7 + .../resources/config/application-swagger.yaml | 16 ++ .../db/migration/V1__create_users_table.sql | 13 + .../V2__add_active_column_to_users.sql | 1 + .../templates/email-verification.html | 15 ++ 65 files changed, 1939 insertions(+) create mode 100644 .env.template create mode 100644 .gitignore create mode 100644 build.gradle.kts create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 src/main/kotlin/com/github/kusitms_bugi/Application.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/application/UserService.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/User.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserApi.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserRepository.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserJpaRepository.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserRepositoryImpl.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserController.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/CheckEmailDuplicateRequest.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/LoginRequest.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/RefreshTokenRequest.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/ResendVerificationEmailRequest.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/VerifyEmailRequest.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/CheckEmailDuplicateResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/LoginResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/RefreshTokenResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/config/ConfigurationPropertiesConfig.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/config/JacksonConfig.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/config/JpaConfig.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/config/RedisConfig.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/config/SecurityConfig.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/config/SwaggerConfig.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/config/TimeZoneConfig.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/entity/BaseEntity.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiException.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/exception/ExceptionLoggingAspect.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/exception/GlobalExceptionHandler.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/filter/ArrayParameterFilter.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/filter/RequestLoggingFilter.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/mail/EmailService.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/properties/FrontendProperties.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/properties/JwtProperties.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/properties/MailProperties.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/response/ApiResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetails.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetailsService.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/security/EmailVerificationTokenProvider.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/security/JwtAuthenticationFilter.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/security/JwtTokenProvider.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/security/TokenType.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/validator/AllowedOrigin.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/global/validator/AllowedOriginValidator.kt create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/config/application-actuator.yaml create mode 100644 src/main/resources/config/application-database.yaml create mode 100644 src/main/resources/config/application-flyway.yaml create mode 100644 src/main/resources/config/application-frontend.yaml create mode 100644 src/main/resources/config/application-jwt.yaml create mode 100644 src/main/resources/config/application-mail.yaml create mode 100644 src/main/resources/config/application-redis.yaml create mode 100644 src/main/resources/config/application-server.yaml create mode 100644 src/main/resources/config/application-swagger.yaml create mode 100644 src/main/resources/db/migration/V1__create_users_table.sql create mode 100644 src/main/resources/db/migration/V2__add_active_column_to_users.sql create mode 100644 src/main/resources/templates/email-verification.html 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/.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/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ea9b03a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,71 @@ +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") + } +} + + +repositories { + mavenCentral() +} + +dependencies { + kotlin() + web() + database() + security() + documentation() + other() +} + +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") +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + 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/user/application/UserService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/application/UserService.kt new file mode 100644 index 0000000..7b9e3a4 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/application/UserService.kt @@ -0,0 +1,92 @@ +package com.github.kusitms_bugi.domain.user.application + +import com.github.kusitms_bugi.domain.user.domain.UserRepository +import com.github.kusitms_bugi.domain.user.presentation.dto.request.* +import com.github.kusitms_bugi.domain.user.presentation.dto.response.* +import com.github.kusitms_bugi.global.exception.ApiException +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 UserService( + 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(UserExceptionCode.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(UserExceptionCode.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/user/domain/User.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/User.kt new file mode 100644 index 0000000..d16f350 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/User.kt @@ -0,0 +1,25 @@ +package com.github.kusitms_bugi.domain.user.domain + +import com.github.kusitms_bugi.global.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table + +@Entity +@Table(name = "users") +class User( + @Column(nullable = false) + var name: String, + + @Column(nullable = false, unique = true) + var email: String, + + @Column(nullable = false) + var password: String, + + @Column + var avatar: String? = null, + + @Column(nullable = false) + var active: Boolean = false +) : BaseEntity() diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserApi.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserApi.kt new file mode 100644 index 0000000..319e643 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserApi.kt @@ -0,0 +1,43 @@ +package com.github.kusitms_bugi.domain.user.domain + +import com.github.kusitms_bugi.domain.user.presentation.dto.request.* +import com.github.kusitms_bugi.domain.user.presentation.dto.response.CheckEmailDuplicateResponse +import com.github.kusitms_bugi.domain.user.presentation.dto.response.LoginResponse +import com.github.kusitms_bugi.domain.user.presentation.dto.response.RefreshTokenResponse +import com.github.kusitms_bugi.domain.user.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 UserApi { + + @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 +} 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..d346623 --- /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 java.util.* + +interface UserRepository { + fun save(user: User): User + fun findById(id: UUID): User? + fun findByEmail(email: String): User? + fun findAll(): List +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserJpaRepository.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserJpaRepository.kt new file mode 100644 index 0000000..49a4883 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserJpaRepository.kt @@ -0,0 +1,9 @@ +package com.github.kusitms_bugi.domain.user.infrastructure + +import com.github.kusitms_bugi.domain.user.domain.User +import org.springframework.data.jpa.repository.JpaRepository +import java.util.* + +interface UserJpaRepository : JpaRepository { + 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..6536ba6 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserRepositoryImpl.kt @@ -0,0 +1,28 @@ +package com.github.kusitms_bugi.domain.user.infrastructure + +import com.github.kusitms_bugi.domain.user.domain.User +import com.github.kusitms_bugi.domain.user.domain.UserRepository +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) + } + + override fun findAll(): List { + return userJpaRepository.findAll() + } +} 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..a9996ac --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserController.kt @@ -0,0 +1,43 @@ +package com.github.kusitms_bugi.domain.user.presentation + +import com.github.kusitms_bugi.domain.user.application.UserService +import com.github.kusitms_bugi.domain.user.domain.UserApi +import com.github.kusitms_bugi.domain.user.presentation.dto.request.* +import com.github.kusitms_bugi.domain.user.presentation.dto.response.CheckEmailDuplicateResponse +import com.github.kusitms_bugi.domain.user.presentation.dto.response.LoginResponse +import com.github.kusitms_bugi.domain.user.presentation.dto.response.RefreshTokenResponse +import com.github.kusitms_bugi.domain.user.presentation.dto.response.SignupResponse +import com.github.kusitms_bugi.global.response.ApiResponse +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController( + private val userService: UserService +) : UserApi { + + override fun checkEmailDuplicate(request: CheckEmailDuplicateRequest): ApiResponse { + return ApiResponse.success(userService.checkEmailDuplicate(request)) + } + + override fun signup(request: SignupRequest): ApiResponse { + return ApiResponse.success(userService.signup(request)) + } + + override fun verifyEmail(request: VerifyEmailRequest): ApiResponse { + userService.verifyEmail(request) + return ApiResponse.success() + } + + override fun resendVerificationEmail(request: ResendVerificationEmailRequest): ApiResponse { + userService.resendVerificationEmail(request) + return ApiResponse.success() + } + + override fun login(request: LoginRequest): ApiResponse { + return ApiResponse.success(userService.login(request)) + } + + override fun refreshToken(request: RefreshTokenRequest): ApiResponse { + return ApiResponse.success(userService.refreshToken(request)) + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/CheckEmailDuplicateRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/CheckEmailDuplicateRequest.kt new file mode 100644 index 0000000..9fa1ce7 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/CheckEmailDuplicateRequest.kt @@ -0,0 +1,10 @@ +package com.github.kusitms_bugi.domain.user.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/user/presentation/dto/request/LoginRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/LoginRequest.kt new file mode 100644 index 0000000..028e50b --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/LoginRequest.kt @@ -0,0 +1,13 @@ +package com.github.kusitms_bugi.domain.user.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/user/presentation/dto/request/RefreshTokenRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/RefreshTokenRequest.kt new file mode 100644 index 0000000..9989889 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/RefreshTokenRequest.kt @@ -0,0 +1,8 @@ +package com.github.kusitms_bugi.domain.user.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/user/presentation/dto/request/ResendVerificationEmailRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/ResendVerificationEmailRequest.kt new file mode 100644 index 0000000..9ed8913 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/ResendVerificationEmailRequest.kt @@ -0,0 +1,15 @@ +package com.github.kusitms_bugi.domain.user.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/user/presentation/dto/request/SignupRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt new file mode 100644 index 0000000..0961757 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt @@ -0,0 +1,34 @@ +package com.github.kusitms_bugi.domain.user.presentation.dto.request + +import com.github.kusitms_bugi.domain.user.domain.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/user/presentation/dto/request/VerifyEmailRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/VerifyEmailRequest.kt new file mode 100644 index 0000000..c8e04f3 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/VerifyEmailRequest.kt @@ -0,0 +1,8 @@ +package com.github.kusitms_bugi.domain.user.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/user/presentation/dto/response/CheckEmailDuplicateResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/CheckEmailDuplicateResponse.kt new file mode 100644 index 0000000..47ac723 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/CheckEmailDuplicateResponse.kt @@ -0,0 +1,5 @@ +package com.github.kusitms_bugi.domain.user.presentation.dto.response + +data class CheckEmailDuplicateResponse( + val isDuplicate: Boolean +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/LoginResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/LoginResponse.kt new file mode 100644 index 0000000..6f208b9 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/LoginResponse.kt @@ -0,0 +1,6 @@ +package com.github.kusitms_bugi.domain.user.presentation.dto.response + +data class LoginResponse( + val accessToken: String, + val refreshToken: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/RefreshTokenResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/RefreshTokenResponse.kt new file mode 100644 index 0000000..eceec02 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/RefreshTokenResponse.kt @@ -0,0 +1,6 @@ +package com.github.kusitms_bugi.domain.user.presentation.dto.response + +data class RefreshTokenResponse( + val accessToken: String, + val refreshToken: String +) diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt new file mode 100644 index 0000000..b4a88a2 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt @@ -0,0 +1,18 @@ +package com.github.kusitms_bugi.domain.user.presentation.dto.response + +import com.github.kusitms_bugi.domain.user.domain.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/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..bd67a2e --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/config/SecurityConfig.kt @@ -0,0 +1,47 @@ +package com.github.kusitms_bugi.global.config + +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.web.cors.CorsConfiguration +import com.github.kusitms_bugi.global.properties.FrontendProperties + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +class SecurityConfig( + private val frontendProperties: FrontendProperties +) { + @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.anyRequest().permitAll() } + + 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..2ebd53e --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/entity/BaseEntity.kt @@ -0,0 +1,36 @@ +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 +} 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..a96e5a9 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt @@ -0,0 +1,38 @@ +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", "요청 파라미터 타입이 올바르지 않습니다."), + + NO_REDIRECT_URI("GLOBAL-401", "리다이렉트 URI가 없습니다."), + UNAUTHORIZED_REDIRECT_URI("GLOBAL-402", "허용되지 않은 리다이렉트 URI입니다."), + NOT_AUTHENTICATED("GLOBAL-403", "인증되지 않은 사용자입니다."), +} + +enum class UserExceptionCode( + override val code: String, + override val message: String +) : ApiExceptionCode { + USER_NOT_FOUND("USER-001", "사용자를 찾을 수 없습니다."), + EMAIL_ALREADY_EXISTS("USER-002", "이미 존재하는 이메일입니다."), + INVALID_TOKEN("USER-003", "유효하지 않은 토큰입니다."), + USER_NOT_ACTIVE("USER-004", "이메일 인증이 완료되지 않은 사용자입니다."), + USER_ALREADY_ACTIVE("USER-005", "이미 이메일 인증이 완료된 사용자입니다."), + INVALID_REFRESH_TOKEN("USER-006", "유효하지 않은 리프레시 토큰입니다."), +} \ 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..c42263c --- /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.MissingServletRequestParameterException +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(MissingServletRequestParameterException::class) + fun handleMissingParameter(ex: MissingServletRequestParameterException): 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..d15f249 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/filter/RequestLoggingFilter.kt @@ -0,0 +1,84 @@ +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.context.annotation.Profile +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingRequestWrapper +import org.springframework.web.util.ContentCachingResponseWrapper + +@Configuration +@Profile("!deployment") +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..6408d62 --- /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..e4ab4f3 --- /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.domain.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( + private 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..530c265 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtAuthenticationFilter.kt @@ -0,0 +1,37 @@ +package com.github.kusitms_bugi.global.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +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 +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + 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) + } + + 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..600804b --- /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.properties.JwtProperties +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 + } + + fun deleteRefreshToken(userId: UUID) { + val key = "refresh_token:$userId" + redisTemplate.delete(key) + } + + 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 (_: 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..8a8ba5f --- /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 + starttls: + 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/templates/email-verification.html b/src/main/resources/templates/email-verification.html new file mode 100644 index 0000000..91db738 --- /dev/null +++ b/src/main/resources/templates/email-verification.html @@ -0,0 +1,15 @@ + + + + + + 이메일 인증 + + +

이메일 인증

+

회원가입을 완료하려면 아래 링크를 클릭해주세요.

+ 이메일 인증하기 +

링크를 클릭할 수 없다면 다음 주소를 복사하여 브라우저에 붙여넣으세요:

+

+ + From 55ceac292ba24dba4324e44bc3a521979f918799 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Mon, 27 Oct 2025 04:56:49 +0900 Subject: [PATCH 02/29] =?UTF-8?q?Feature/=EB=B0=B0=ED=8F=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=EC=B6=95=20(#2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Dockerfile 추가 * ci: GitHub Actions 배포 파이프라인 구축 --- .github/workflows/deploy.yml | 59 ++++++++++++++++++++++++++++++++++++ Dockerfile | 30 ++++++++++++++++++ build.gradle.kts | 3 ++ 3 files changed, 92 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 Dockerfile diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3168fc7 --- /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 }} -p 8080:8080 -d ${{ secrets.CONTAINER_REGISTRY }}/server:${{ github.sha }} + docker image prune -f \ 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 index ea9b03a..ca16de2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,9 @@ kotlin { } } +springBoot { + buildInfo() +} repositories { mavenCentral() From 2930050f8cb2122ef489b7b2fa0b5c1d3784cbab Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Wed, 29 Oct 2025 13:21:09 +0900 Subject: [PATCH 03/29] =?UTF-8?q?chore(ci):=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=EC=9D=98=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3168fc7..ad3e95e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,6 +54,6 @@ jobs: docker rm server fi - docker pull ${{ secrets.CONTAINER_REGISTRY }}/server:${{ github.sha }} + 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 run --name server --restart unless-stopped --env-file ${{ secrets.ENV_FILE }} -p 8080:8080 -d ${{ secrets.CONTAINER_REGISTRY }}/server:${{ github.sha }} - docker image prune -f \ No newline at end of file + docker image prune -f From 9dee8fda2ecc73cca0be6d0d0a4dea931376d8b6 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Wed, 29 Oct 2025 14:06:37 +0900 Subject: [PATCH 04/29] Update deploy.yml --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ad3e95e..918243b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,6 +54,6 @@ jobs: 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 run --name server --restart unless-stopped --env-file ${{ secrets.ENV_FILE }} -p 8080:8080 -d ${{ secrets.CONTAINER_REGISTRY }}/server:${{ github.sha }} + 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 From bf12bab46474b002b61f7ab1ec08140a84a27e87 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Sat, 1 Nov 2025 14:58:34 +0900 Subject: [PATCH 05/29] =?UTF-8?q?refactor(email):=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/email-verification.html | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html index 91db738..5600b48 100644 --- a/src/main/resources/templates/email-verification.html +++ b/src/main/resources/templates/email-verification.html @@ -1,15 +1,31 @@ - + - - - 이메일 인증 + - -

이메일 인증

-

회원가입을 완료하려면 아래 링크를 클릭해주세요.

- 이메일 인증하기 -

링크를 클릭할 수 없다면 다음 주소를 복사하여 브라우저에 붙여넣으세요:

-

+ + +
+
+
+

+ 메일 인증 안내 +

+

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

+ + 메일 인증 + +
+

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

+
+
+
- + \ No newline at end of file From dbd0654c7a5a3dc6ef290a7803ff227f387bcbc3 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Fri, 7 Nov 2025 14:11:56 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat(session):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/application/SessionService.kt | 126 ++++++++++++++++++ .../domain/session/domain/Session.kt | 19 +++ .../domain/session/domain/SessionApi.kt | 43 ++++++ .../domain/session/domain/SessionMetric.kt | 19 +++ .../session/domain/SessionRepository.kt | 9 ++ .../domain/session/domain/SessionStatus.kt | 8 ++ .../session/domain/SessionStatusHistory.kt | 20 +++ .../infrastructure/SessionJpaRepository.kt | 9 ++ .../infrastructure/SessionRepositoryImpl.kt | 24 ++++ .../session/presentation/SessionController.kt | 45 +++++++ .../dto/request/SaveMetricsRequest.kt | 14 ++ .../dto/response/CreateSessionResponse.kt | 7 + .../dto/response/GetSessionResponse.kt | 8 ++ .../global/config/SecurityConfig.kt | 14 +- .../global/filter/RequestLoggingFilter.kt | 2 - .../global/security/CustomUserDetails.kt | 2 +- .../migration/V3__create_session_tables.sql | 25 ++++ .../V4__create_session_metric_table.sql | 14 ++ 18 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/application/SessionService.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/Session.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionApi.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionMetric.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionRepository.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatus.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatusHistory.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionJpaRepository.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionRepositoryImpl.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/SessionController.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/request/SaveMetricsRequest.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/CreateSessionResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionResponse.kt create mode 100644 src/main/resources/db/migration/V3__create_session_tables.sql create mode 100644 src/main/resources/db/migration/V4__create_session_metric_table.sql 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..c74ea39 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/application/SessionService.kt @@ -0,0 +1,126 @@ +package com.github.kusitms_bugi.domain.session.application + +import com.github.kusitms_bugi.domain.session.domain.* +import com.github.kusitms_bugi.domain.session.presentation.dto.request.SaveMetricsRequest +import com.github.kusitms_bugi.domain.session.presentation.dto.response.CreateSessionResponse +import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessionResponse +import com.github.kusitms_bugi.domain.user.domain.User +import com.github.kusitms_bugi.global.exception.ApiException +import com.github.kusitms_bugi.global.exception.UserExceptionCode +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import java.util.* + +@Service +class SessionService( + private val sessionRepository: SessionRepository +) { + + @Transactional + fun createSession(user: User): CreateSessionResponse { + val session = Session(user = user) + session.statusHistory.add( + SessionStatusHistory( + session = session, + status = SessionStatus.STARTED, + timestamp = LocalDateTime.now() + ) + ) + + return sessionRepository.save(session) + .let { CreateSessionResponse(sessionId = it.id) } + } + + @Transactional(readOnly = true) + fun getSession(sessionId: UUID): GetSessionResponse { + val session = sessionRepository.findById(sessionId) + ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + + val totalActiveTimeSeconds = calculateActiveTime(session) + + return GetSessionResponse( + sessionId = session.id, + totalActiveTimeSeconds = totalActiveTimeSeconds + ) + } + + private fun calculateActiveTime(session: Session): Long { + val history = session.statusHistory.sortedBy { it.timestamp } + var totalSeconds = 0L + var activeStartTime: LocalDateTime? = null + + history.forEach { event -> + when (event.status) { + SessionStatus.STARTED, SessionStatus.RESUMED -> { + activeStartTime = event.timestamp + } + SessionStatus.PAUSED, SessionStatus.STOPPED -> { + activeStartTime?.let { start -> + totalSeconds += ChronoUnit.SECONDS.between(start, event.timestamp) + activeStartTime = null + } + } + } + } + + return totalSeconds + } + + @Transactional + fun pauseSession(sessionId: UUID) { + val session = sessionRepository.findById(sessionId) + ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + + session.statusHistory.add( + SessionStatusHistory( + session = session, + status = SessionStatus.PAUSED, + timestamp = LocalDateTime.now() + ) + ) + } + + @Transactional + fun resumeSession(sessionId: UUID) { + val session = sessionRepository.findById(sessionId) + ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + + session.statusHistory.add( + SessionStatusHistory( + session = session, + status = SessionStatus.RESUMED, + timestamp = LocalDateTime.now() + ) + ) + } + + @Transactional + fun stopSession(sessionId: UUID) { + val session = sessionRepository.findById(sessionId) + ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + + session.statusHistory.add( + SessionStatusHistory( + session = session, + status = SessionStatus.STOPPED, + timestamp = LocalDateTime.now() + ) + ) + } + + @Transactional + fun saveMetrics(request: SaveMetricsRequest) { + val session = sessionRepository.findById(request.sessionId) + ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + + request.metrics.forEach { metricData -> + SessionMetric( + session = session, + score = metricData.score, + timestamp = metricData.timestamp + ).let { session.metrics.add(it) } + } + } +} 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..584dbca --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/Session.kt @@ -0,0 +1,19 @@ +package com.github.kusitms_bugi.domain.session.domain + +import com.github.kusitms_bugi.domain.user.domain.User +import com.github.kusitms_bugi.global.entity.BaseEntity +import jakarta.persistence.* + +@Entity +@Table(name = "session") +class Session( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + var user: User, + + @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) + var statusHistory: MutableList = mutableListOf(), + + @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) + var metrics: MutableList = mutableListOf() +) : BaseEntity() diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionApi.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionApi.kt new file mode 100644 index 0000000..2401674 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionApi.kt @@ -0,0 +1,43 @@ +package com.github.kusitms_bugi.domain.session.domain + +import com.github.kusitms_bugi.domain.session.presentation.dto.request.SaveMetricsRequest +import com.github.kusitms_bugi.domain.session.presentation.dto.response.CreateSessionResponse +import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessionResponse +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.* +import java.util.* + +@Tag(name = "세션") +@RequestMapping("/sessions") +interface SessionApi { + + @Operation(summary = "세션 생성") + @PostMapping + fun createSession(@Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails): ApiResponse + + @Operation(summary = "세션 조회") + @GetMapping("/{sessionId}") + fun getSession(@PathVariable sessionId: UUID): ApiResponse + + @Operation(summary = "세션 일시정지") + @PatchMapping("/{sessionId}/pause") + fun pauseSession(@PathVariable sessionId: UUID): ApiResponse + + @Operation(summary = "세션 재개") + @PatchMapping("/{sessionId}/resume") + fun resumeSession(@PathVariable sessionId: UUID): ApiResponse + + @Operation(summary = "세션 중단") + @PatchMapping("/{sessionId}/stop") + fun stopSession(@PathVariable sessionId: UUID): ApiResponse + + @Operation(summary = "세션 메트릭 저장") + @PostMapping("/{sessionId}/metrics") + fun saveMetrics(@PathVariable sessionId: UUID, @Validated @RequestBody request: SaveMetricsRequest): ApiResponse +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionMetric.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionMetric.kt new file mode 100644 index 0000000..139a1e0 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionMetric.kt @@ -0,0 +1,19 @@ +package com.github.kusitms_bugi.domain.session.domain + +import com.github.kusitms_bugi.global.entity.BaseEntity +import jakarta.persistence.* +import java.time.LocalDateTime + +@Entity +@Table(name = "session_metric") +class SessionMetric( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + var session: Session, + + @Column(nullable = false) + var score: Double, + + @Column(nullable = false) + var timestamp: LocalDateTime +) : BaseEntity() 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..ceb8b42 --- /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 java.util.* + +interface SessionRepository { + fun save(session: Session): Session + fun findById(id: UUID): Session? + fun findByUserId(userId: UUID): List +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatus.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatus.kt new file mode 100644 index 0000000..8e81c9c --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatus.kt @@ -0,0 +1,8 @@ +package com.github.kusitms_bugi.domain.session.domain + +enum class SessionStatus { + STARTED, + PAUSED, + RESUMED, + STOPPED +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatusHistory.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatusHistory.kt new file mode 100644 index 0000000..58a41d7 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatusHistory.kt @@ -0,0 +1,20 @@ +package com.github.kusitms_bugi.domain.session.domain + +import com.github.kusitms_bugi.global.entity.BaseEntity +import jakarta.persistence.* +import java.time.LocalDateTime + +@Entity +@Table(name = "session_status") +class SessionStatusHistory( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + var session: Session, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + var status: SessionStatus, + + @Column(nullable = false) + var timestamp: LocalDateTime = LocalDateTime.now() +) : BaseEntity() diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionJpaRepository.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionJpaRepository.kt new file mode 100644 index 0000000..eff94f3 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionJpaRepository.kt @@ -0,0 +1,9 @@ +package com.github.kusitms_bugi.domain.session.infrastructure + +import com.github.kusitms_bugi.domain.session.domain.Session +import org.springframework.data.jpa.repository.JpaRepository +import java.util.* + +interface SessionJpaRepository : JpaRepository { + fun findByUserId(userId: UUID): List +} 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..69b50f3 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionRepositoryImpl.kt @@ -0,0 +1,24 @@ +package com.github.kusitms_bugi.domain.session.infrastructure + +import com.github.kusitms_bugi.domain.session.domain.Session +import com.github.kusitms_bugi.domain.session.domain.SessionRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Repository +import java.util.* + +@Repository +class SessionRepositoryImpl( + private val sessionJpaRepository: SessionJpaRepository +) : SessionRepository { + override fun save(session: Session): Session { + return sessionJpaRepository.save(session) + } + + override fun findById(id: UUID): Session? { + return sessionJpaRepository.findByIdOrNull(id) + } + + override fun findByUserId(userId: UUID): List { + return sessionJpaRepository.findByUserId(userId) + } +} 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..df930c1 --- /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.domain.SessionApi +import com.github.kusitms_bugi.domain.session.presentation.dto.request.SaveMetricsRequest +import com.github.kusitms_bugi.domain.session.presentation.dto.response.CreateSessionResponse +import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessionResponse +import com.github.kusitms_bugi.global.response.ApiResponse +import com.github.kusitms_bugi.global.security.CustomUserDetails +import org.springframework.web.bind.annotation.RestController +import java.util.* + +@RestController +class SessionController( + private val sessionService: SessionService +) : SessionApi { + + override fun createSession(userDetails: CustomUserDetails): ApiResponse { + return ApiResponse.success(sessionService.createSession(userDetails.user)) + } + + override fun getSession(sessionId: UUID): ApiResponse { + return ApiResponse.success(sessionService.getSession(sessionId)) + } + + override fun pauseSession(sessionId: UUID): ApiResponse { + sessionService.pauseSession(sessionId) + return ApiResponse.success() + } + + override fun resumeSession(sessionId: UUID): ApiResponse { + sessionService.resumeSession(sessionId) + return ApiResponse.success() + } + + override fun stopSession(sessionId: UUID): ApiResponse { + sessionService.stopSession(sessionId) + return ApiResponse.success() + } + + override fun saveMetrics(sessionId: UUID, request: SaveMetricsRequest): ApiResponse { + sessionService.saveMetrics(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..a5295eb --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/request/SaveMetricsRequest.kt @@ -0,0 +1,14 @@ +package com.github.kusitms_bugi.domain.session.presentation.dto.request + +import java.time.LocalDateTime +import java.util.* + +data class SaveMetricsRequest( + val sessionId: UUID, + val metrics: List +) + +data class MetricData( + val score: Double, + 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/GetSessionResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionResponse.kt new file mode 100644 index 0000000..19806c5 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionResponse.kt @@ -0,0 +1,8 @@ +package com.github.kusitms_bugi.domain.session.presentation.dto.response + +import java.util.* + +data class GetSessionResponse( + val sessionId: UUID, + val totalActiveTimeSeconds: Long +) 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 index bd67a2e..bc11fd0 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/config/SecurityConfig.kt @@ -1,5 +1,7 @@ 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 @@ -9,14 +11,15 @@ import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.web.cors.CorsConfiguration -import com.github.kusitms_bugi.global.properties.FrontendProperties @Configuration @EnableWebSecurity @EnableMethodSecurity(prePostEnabled = true) class SecurityConfig( - private val frontendProperties: FrontendProperties + private val frontendProperties: FrontendProperties, + private val jwtAuthenticationFilter: JwtAuthenticationFilter ) { @Bean fun passwordEncoder(): PasswordEncoder { @@ -40,7 +43,12 @@ class SecurityConfig( } } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .authorizeHttpRequests { auth -> auth.anyRequest().permitAll() } + .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/filter/RequestLoggingFilter.kt b/src/main/kotlin/com/github/kusitms_bugi/global/filter/RequestLoggingFilter.kt index d15f249..8248fcb 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/filter/RequestLoggingFilter.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/filter/RequestLoggingFilter.kt @@ -5,13 +5,11 @@ import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.util.ContentCachingRequestWrapper import org.springframework.web.util.ContentCachingResponseWrapper @Configuration -@Profile("!deployment") class RequestLoggingFilter : OncePerRequestFilter() { private val _logger = LoggerFactory.getLogger(RequestLoggingFilter::class.java) 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 index e4ab4f3..de642b5 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetails.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetails.kt @@ -7,7 +7,7 @@ import org.springframework.security.core.userdetails.UserDetails import java.util.* class CustomUserDetails( - private val user: User + val user: User ) : UserDetails { fun getId(): UUID = user.id 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); From 3ce4b681d37ce06ce5f2c1f18ce4925e6ef3bd4c Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Fri, 7 Nov 2025 23:30:43 +0900 Subject: [PATCH 07/29] =?UTF-8?q?refactor(session):=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/application/SessionService.kt | 138 ++++++++++-------- .../domain/session/domain/Session.kt | 39 +++-- .../domain/session/domain/SessionApi.kt | 43 ------ .../domain/session/domain/SessionMetric.kt | 19 --- .../session/domain/SessionRepository.kt | 4 +- .../domain/session/domain/SessionStatus.kt | 8 - .../session/domain/SessionStatusHistory.kt | 20 --- .../infrastructure/SessionJpaRepository.kt | 9 -- .../infrastructure/SessionRepositoryImpl.kt | 14 +- .../session/infrastructure/jpa/Session.kt | 70 +++++++++ .../jpa/SessionJpaRepository.kt | 11 ++ .../security/SessionPermissionEvaluator.kt | 12 ++ .../domain/session/presentation/SessionApi.kt | 57 ++++++++ .../session/presentation/SessionController.kt | 29 ++-- ...esponse.kt => GetSessionReportResponse.kt} | 2 +- .../kusitms_bugi/domain/user/domain/User.kt | 33 ++--- .../domain/user/domain/UserRepository.kt | 2 +- .../user/infrastructure/UserRepositoryImpl.kt | 7 +- .../domain/user/infrastructure/jpa/User.kt | 29 ++++ .../{ => jpa}/UserJpaRepository.kt | 5 +- .../user/{domain => presentation}/UserApi.kt | 4 +- .../user/presentation/UserController.kt | 1 - .../presentation/dto/request/SignupRequest.kt | 2 +- .../dto/response/SignupResponse.kt | 2 +- .../kusitms_bugi/global/entity/BaseEntity.kt | 7 + .../global/exception/ApiExceptionCode.kt | 28 ++-- .../exception/GlobalExceptionHandler.kt | 6 +- .../global/security/CustomUserDetails.kt | 2 +- 28 files changed, 353 insertions(+), 250 deletions(-) delete mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionApi.kt delete mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionMetric.kt delete mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatus.kt delete mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatusHistory.kt delete mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionJpaRepository.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/Session.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/SessionJpaRepository.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/security/SessionPermissionEvaluator.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/SessionApi.kt rename src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/{GetSessionResponse.kt => GetSessionReportResponse.kt} (81%) create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/User.kt rename src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/{ => jpa}/UserJpaRepository.kt (61%) rename src/main/kotlin/com/github/kusitms_bugi/domain/user/{domain => presentation}/UserApi.kt (97%) 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 index c74ea39..34e08a5 100644 --- 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 @@ -1,119 +1,113 @@ package com.github.kusitms_bugi.domain.session.application -import com.github.kusitms_bugi.domain.session.domain.* +import com.github.kusitms_bugi.domain.session.domain.SessionRepository +import com.github.kusitms_bugi.domain.session.domain.SessionStatus +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.SaveMetricsRequest import com.github.kusitms_bugi.domain.session.presentation.dto.response.CreateSessionResponse -import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessionResponse -import com.github.kusitms_bugi.domain.user.domain.User +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.UserExceptionCode +import com.github.kusitms_bugi.global.exception.SessionExceptionCode +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.time.temporal.ChronoUnit -import java.util.* @Service class SessionService( private val sessionRepository: SessionRepository ) { - @Transactional - fun createSession(user: User): CreateSessionResponse { - val session = Session(user = user) - session.statusHistory.add( - SessionStatusHistory( - session = session, - status = SessionStatus.STARTED, - timestamp = LocalDateTime.now() - ) - ) - - return sessionRepository.save(session) - .let { CreateSessionResponse(sessionId = it.id) } - } - @Transactional(readOnly = true) - fun getSession(sessionId: UUID): GetSessionResponse { - val session = sessionRepository.findById(sessionId) - ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") + fun getSession(session: Session): GetSessionReportResponse { + session.lastStatus() + .takeIf { it == SessionStatus.STOPPED } + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_STOPPED) val totalActiveTimeSeconds = calculateActiveTime(session) - return GetSessionResponse( + return GetSessionReportResponse( sessionId = session.id, totalActiveTimeSeconds = totalActiveTimeSeconds ) } - private fun calculateActiveTime(session: Session): Long { - val history = session.statusHistory.sortedBy { it.timestamp } - var totalSeconds = 0L - var activeStartTime: LocalDateTime? = null + @Transactional + fun createSession(user: User): CreateSessionResponse { + val session = Session(user = user) - history.forEach { event -> - when (event.status) { - SessionStatus.STARTED, SessionStatus.RESUMED -> { - activeStartTime = event.timestamp - } - SessionStatus.PAUSED, SessionStatus.STOPPED -> { - activeStartTime?.let { start -> - totalSeconds += ChronoUnit.SECONDS.between(start, event.timestamp) - activeStartTime = null - } - } - } - } + session.statusHistory.add( + SessionStatusHistory( + session = session, + status = SessionStatus.STARTED + ) + ) - return totalSeconds + return CreateSessionResponse(sessionId = sessionRepository.save(session).id) } @Transactional - fun pauseSession(sessionId: UUID) { - val session = sessionRepository.findById(sessionId) - ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") + fun pauseSession(session: Session) { + session.lastStatus() + .takeIf { it == SessionStatus.STARTED || it == SessionStatus.RESUMED } + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_RESUMED) session.statusHistory.add( SessionStatusHistory( session = session, - status = SessionStatus.PAUSED, - timestamp = LocalDateTime.now() + status = SessionStatus.PAUSED ) ) + + sessionRepository.save(session) } @Transactional - fun resumeSession(sessionId: UUID) { - val session = sessionRepository.findById(sessionId) - ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") + fun resumeSession(session: Session) { + session.lastStatus() + .takeIf { it == SessionStatus.PAUSED } + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_PAUSED) session.statusHistory.add( SessionStatusHistory( session = session, - status = SessionStatus.RESUMED, - timestamp = LocalDateTime.now() + status = SessionStatus.RESUMED ) ) + + sessionRepository.save(session) } @Transactional - fun stopSession(sessionId: UUID) { - val session = sessionRepository.findById(sessionId) - ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") + fun stopSession(session: Session) { + session.lastStatus() + .takeUnless { it == SessionStatus.STOPPED } + ?: throw ApiException(SessionExceptionCode.SESSION_ALREADY_STOPPED) session.statusHistory.add( SessionStatusHistory( session = session, - status = SessionStatus.STOPPED, - timestamp = LocalDateTime.now() + status = SessionStatus.STOPPED ) ) + + sessionRepository.save(session) } @Transactional - fun saveMetrics(request: SaveMetricsRequest) { - val session = sessionRepository.findById(request.sessionId) - ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) + @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") + fun saveMetrics(session: Session, request: SaveMetricsRequest) { + session.lastStatus() + .takeIf { it == SessionStatus.STARTED || it == SessionStatus.RESUMED } + ?: throw ApiException(SessionExceptionCode.SESSION_NOT_ACTIVE) request.metrics.forEach { metricData -> SessionMetric( @@ -122,5 +116,29 @@ class SessionService( timestamp = metricData.timestamp ).let { session.metrics.add(it) } } + + sessionRepository.save(session) + } +} + +private fun calculateActiveTime(session: Session): Long { + val history = session.statusHistory.sortedBy { it.timestamp } + var totalSeconds = 0L + var activeStartTime: LocalDateTime? = null + + history.forEach { event -> + when (event.status) { + SessionStatus.STARTED, SessionStatus.RESUMED -> { + activeStartTime = event.timestamp + } + SessionStatus.PAUSED, SessionStatus.STOPPED -> { + activeStartTime?.let { start -> + totalSeconds += ChronoUnit.SECONDS.between(start, event.timestamp) + activeStartTime = null + } + } + } } + + return totalSeconds } 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 index 584dbca..535dbc3 100644 --- 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 @@ -1,19 +1,30 @@ package com.github.kusitms_bugi.domain.session.domain -import com.github.kusitms_bugi.domain.user.domain.User -import com.github.kusitms_bugi.global.entity.BaseEntity -import jakarta.persistence.* +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User +import com.github.kusitms_bugi.global.entity.BaseField +import java.time.LocalDateTime -@Entity -@Table(name = "session") -class Session( - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - var user: User, +enum class SessionStatus { + STARTED, + PAUSED, + RESUMED, + STOPPED +} - @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) - var statusHistory: MutableList = mutableListOf(), +interface SessionField : BaseField { + var user: User + var statusHistory: MutableList + var metrics: MutableList +} - @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) - var metrics: MutableList = mutableListOf() -) : BaseEntity() +interface SessionStatusHistoryField : BaseField { + var session: SESSION + var status: SessionStatus + var timestamp: LocalDateTime +} + +interface SessionMetricField : BaseField { + var session: SESSION + var score: Double + var timestamp: LocalDateTime +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionApi.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionApi.kt deleted file mode 100644 index 2401674..0000000 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionApi.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.github.kusitms_bugi.domain.session.domain - -import com.github.kusitms_bugi.domain.session.presentation.dto.request.SaveMetricsRequest -import com.github.kusitms_bugi.domain.session.presentation.dto.response.CreateSessionResponse -import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessionResponse -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.* -import java.util.* - -@Tag(name = "세션") -@RequestMapping("/sessions") -interface SessionApi { - - @Operation(summary = "세션 생성") - @PostMapping - fun createSession(@Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails): ApiResponse - - @Operation(summary = "세션 조회") - @GetMapping("/{sessionId}") - fun getSession(@PathVariable sessionId: UUID): ApiResponse - - @Operation(summary = "세션 일시정지") - @PatchMapping("/{sessionId}/pause") - fun pauseSession(@PathVariable sessionId: UUID): ApiResponse - - @Operation(summary = "세션 재개") - @PatchMapping("/{sessionId}/resume") - fun resumeSession(@PathVariable sessionId: UUID): ApiResponse - - @Operation(summary = "세션 중단") - @PatchMapping("/{sessionId}/stop") - fun stopSession(@PathVariable sessionId: UUID): ApiResponse - - @Operation(summary = "세션 메트릭 저장") - @PostMapping("/{sessionId}/metrics") - fun saveMetrics(@PathVariable sessionId: UUID, @Validated @RequestBody request: SaveMetricsRequest): ApiResponse -} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionMetric.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionMetric.kt deleted file mode 100644 index 139a1e0..0000000 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionMetric.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.github.kusitms_bugi.domain.session.domain - -import com.github.kusitms_bugi.global.entity.BaseEntity -import jakarta.persistence.* -import java.time.LocalDateTime - -@Entity -@Table(name = "session_metric") -class SessionMetric( - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "session_id", nullable = false) - var session: Session, - - @Column(nullable = false) - var score: Double, - - @Column(nullable = false) - var timestamp: LocalDateTime -) : BaseEntity() 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 index ceb8b42..0d26a1c 100644 --- 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 @@ -1,9 +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 save(session: Session): Session fun findById(id: UUID): Session? - fun findByUserId(userId: UUID): List + fun save(session: Session): Session } diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatus.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatus.kt deleted file mode 100644 index 8e81c9c..0000000 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatus.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.github.kusitms_bugi.domain.session.domain - -enum class SessionStatus { - STARTED, - PAUSED, - RESUMED, - STOPPED -} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatusHistory.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatusHistory.kt deleted file mode 100644 index 58a41d7..0000000 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/session/domain/SessionStatusHistory.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.kusitms_bugi.domain.session.domain - -import com.github.kusitms_bugi.global.entity.BaseEntity -import jakarta.persistence.* -import java.time.LocalDateTime - -@Entity -@Table(name = "session_status") -class SessionStatusHistory( - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "session_id", nullable = false) - var session: Session, - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 50) - var status: SessionStatus, - - @Column(nullable = false) - var timestamp: LocalDateTime = LocalDateTime.now() -) : BaseEntity() diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionJpaRepository.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionJpaRepository.kt deleted file mode 100644 index eff94f3..0000000 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/SessionJpaRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.kusitms_bugi.domain.session.infrastructure - -import com.github.kusitms_bugi.domain.session.domain.Session -import org.springframework.data.jpa.repository.JpaRepository -import java.util.* - -interface SessionJpaRepository : JpaRepository { - fun findByUserId(userId: UUID): List -} 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 index 69b50f3..3577f5c 100644 --- 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 @@ -1,8 +1,8 @@ package com.github.kusitms_bugi.domain.session.infrastructure -import com.github.kusitms_bugi.domain.session.domain.Session import com.github.kusitms_bugi.domain.session.domain.SessionRepository -import org.springframework.data.repository.findByIdOrNull +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.* @@ -10,15 +10,11 @@ import java.util.* class SessionRepositoryImpl( private val sessionJpaRepository: SessionJpaRepository ) : SessionRepository { - override fun save(session: Session): Session { - return sessionJpaRepository.save(session) - } - override fun findById(id: UUID): Session? { - return sessionJpaRepository.findByIdOrNull(id) + return sessionJpaRepository.findById(id).orElse(null) } - override fun findByUserId(userId: UUID): List { - return sessionJpaRepository.findByUserId(userId) + override fun save(session: Session): Session { + return sessionJpaRepository.save(session) } } 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..4dc61e9 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/Session.kt @@ -0,0 +1,70 @@ +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.springframework.data.annotation.CreatedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime + +@Entity +@Table(name = "session") +@NamedEntityGraph( + name = "Session.withAll", + attributeNodes = [ + NamedAttributeNode("user"), + NamedAttributeNode("statusHistory"), + NamedAttributeNode("metrics") + ] +) +class Session( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + override var user: User, + + @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) + override var statusHistory: MutableList = mutableListOf(), + + @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) + override var metrics: MutableList = mutableListOf() +) : BaseEntity(), SessionField { + + fun lastStatus(): SessionStatus? = statusHistory.maxByOrNull { it.timestamp }?.status +} + +@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: Double, + + @CreatedDate + @Column(nullable = false) + override var timestamp: LocalDateTime = LocalDateTime.now() +) : 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..2fe5ec4 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/SessionJpaRepository.kt @@ -0,0 +1,11 @@ +package com.github.kusitms_bugi.domain.session.infrastructure.jpa + +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import java.util.* + +interface SessionJpaRepository : JpaRepository { + + @EntityGraph("Session.withAll") + override fun findById(id: UUID): Optional +} \ No newline at end of file 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..47409f2 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/SessionApi.kt @@ -0,0 +1,57 @@ +package com.github.kusitms_bugi.domain.session.presentation + +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session +import com.github.kusitms_bugi.domain.session.presentation.dto.request.SaveMetricsRequest +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.* + +@Tag(name = "세션") +@RequestMapping("/sessions") +interface SessionApi { + + @Operation(summary = "세션 조회") + @GetMapping("/{session}/report") + fun getSessionReport( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session + ): ApiResponse + + @Operation(summary = "세션 생성") + @PostMapping + fun createSession( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "세션 일시정지") + @PatchMapping("/{session}/pause") + fun pauseSession( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session + ): ApiResponse + + @Operation(summary = "세션 재개") + @PatchMapping("/{session}/resume") + fun resumeSession( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session + ): ApiResponse + + @Operation(summary = "세션 중단") + @PatchMapping("/{session}/stop") + fun stopSession( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session + ): ApiResponse + + @Operation(summary = "세션 메트릭 저장") + @PostMapping("/{session}/metrics") + fun saveMetrics( + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session, + @Validated @RequestBody request: SaveMetricsRequest + ): 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 index df930c1..6eff288 100644 --- 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 @@ -1,45 +1,44 @@ package com.github.kusitms_bugi.domain.session.presentation import com.github.kusitms_bugi.domain.session.application.SessionService -import com.github.kusitms_bugi.domain.session.domain.SessionApi +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session import com.github.kusitms_bugi.domain.session.presentation.dto.request.SaveMetricsRequest import com.github.kusitms_bugi.domain.session.presentation.dto.response.CreateSessionResponse -import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessionResponse +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.web.bind.annotation.RestController -import java.util.* @RestController class SessionController( private val sessionService: SessionService ) : SessionApi { - override fun createSession(userDetails: CustomUserDetails): ApiResponse { - return ApiResponse.success(sessionService.createSession(userDetails.user)) + override fun getSessionReport(session: Session): ApiResponse { + return ApiResponse.success(sessionService.getSession(session)) } - override fun getSession(sessionId: UUID): ApiResponse { - return ApiResponse.success(sessionService.getSession(sessionId)) + override fun createSession(userDetails: CustomUserDetails): ApiResponse { + return ApiResponse.success(sessionService.createSession(userDetails.user)) } - override fun pauseSession(sessionId: UUID): ApiResponse { - sessionService.pauseSession(sessionId) + override fun pauseSession(session: Session): ApiResponse { + sessionService.pauseSession(session) return ApiResponse.success() } - override fun resumeSession(sessionId: UUID): ApiResponse { - sessionService.resumeSession(sessionId) + override fun resumeSession(session: Session): ApiResponse { + sessionService.resumeSession(session) return ApiResponse.success() } - override fun stopSession(sessionId: UUID): ApiResponse { - sessionService.stopSession(sessionId) + override fun stopSession(session: Session): ApiResponse { + sessionService.stopSession(session) return ApiResponse.success() } - override fun saveMetrics(sessionId: UUID, request: SaveMetricsRequest): ApiResponse { - sessionService.saveMetrics(request) + override fun saveMetrics(session: Session, request: SaveMetricsRequest): ApiResponse { + sessionService.saveMetrics(session, request) return ApiResponse.success() } } diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionReportResponse.kt similarity index 81% rename from src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionResponse.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionReportResponse.kt index 19806c5..1d89302 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionResponse.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/session/presentation/dto/response/GetSessionReportResponse.kt @@ -2,7 +2,7 @@ package com.github.kusitms_bugi.domain.session.presentation.dto.response import java.util.* -data class GetSessionResponse( +data class GetSessionReportResponse( val sessionId: UUID, val totalActiveTimeSeconds: Long ) 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 index d16f350..a285edc 100644 --- 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 @@ -1,25 +1,12 @@ package com.github.kusitms_bugi.domain.user.domain -import com.github.kusitms_bugi.global.entity.BaseEntity -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Table - -@Entity -@Table(name = "users") -class User( - @Column(nullable = false) - var name: String, - - @Column(nullable = false, unique = true) - var email: String, - - @Column(nullable = false) - var password: String, - - @Column - var avatar: String? = null, - - @Column(nullable = false) - var active: Boolean = false -) : BaseEntity() +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 index d346623..8771262 100644 --- 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 @@ -1,10 +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? - fun findAll(): List } 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 index 6536ba6..d579355 100644 --- 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 @@ -1,7 +1,8 @@ package com.github.kusitms_bugi.domain.user.infrastructure -import com.github.kusitms_bugi.domain.user.domain.User 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.* @@ -21,8 +22,4 @@ class UserRepositoryImpl( override fun findByEmail(email: String): User? { return userJpaRepository.findByEmail(email) } - - override fun findAll(): List { - return userJpaRepository.findAll() - } } 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/UserJpaRepository.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/UserJpaRepository.kt similarity index 61% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserJpaRepository.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/UserJpaRepository.kt index 49a4883..b8456c3 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/UserJpaRepository.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/infrastructure/jpa/UserJpaRepository.kt @@ -1,9 +1,8 @@ -package com.github.kusitms_bugi.domain.user.infrastructure +package com.github.kusitms_bugi.domain.user.infrastructure.jpa -import com.github.kusitms_bugi.domain.user.domain.User 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/domain/UserApi.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserApi.kt similarity index 97% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserApi.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserApi.kt index 319e643..f7dd656 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/domain/UserApi.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserApi.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.domain +package com.github.kusitms_bugi.domain.user.presentation import com.github.kusitms_bugi.domain.user.presentation.dto.request.* import com.github.kusitms_bugi.domain.user.presentation.dto.response.CheckEmailDuplicateResponse @@ -40,4 +40,4 @@ interface UserApi { @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/user/presentation/UserController.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/UserController.kt index a9996ac..e47358e 100644 --- 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 @@ -1,7 +1,6 @@ package com.github.kusitms_bugi.domain.user.presentation import com.github.kusitms_bugi.domain.user.application.UserService -import com.github.kusitms_bugi.domain.user.domain.UserApi import com.github.kusitms_bugi.domain.user.presentation.dto.request.* import com.github.kusitms_bugi.domain.user.presentation.dto.response.CheckEmailDuplicateResponse import com.github.kusitms_bugi.domain.user.presentation.dto.response.LoginResponse diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt index 0961757..5a7b141 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt @@ -1,6 +1,6 @@ package com.github.kusitms_bugi.domain.user.presentation.dto.request -import com.github.kusitms_bugi.domain.user.domain.User +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 diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt index b4a88a2..803caa4 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt @@ -1,6 +1,6 @@ package com.github.kusitms_bugi.domain.user.presentation.dto.response -import com.github.kusitms_bugi.domain.user.domain.User +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User import java.util.* data class SignupResponse( 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 index 2ebd53e..ed8731d 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/entity/BaseEntity.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/entity/BaseEntity.kt @@ -34,3 +34,10 @@ abstract class BaseEntity { 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/ApiExceptionCode.kt b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt index a96e5a9..27e4f2e 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt @@ -20,19 +20,29 @@ enum class GlobalExceptionCode( MISSING_PARAMETER("GLOBAL-302", "필수 요청 파라미터가 누락되었습니다."), TYPE_MISMATCH("GLOBAL-303", "요청 파라미터 타입이 올바르지 않습니다."), - NO_REDIRECT_URI("GLOBAL-401", "리다이렉트 URI가 없습니다."), - UNAUTHORIZED_REDIRECT_URI("GLOBAL-402", "허용되지 않은 리다이렉트 URI입니다."), - NOT_AUTHENTICATED("GLOBAL-403", "인증되지 않은 사용자입니다."), + NOT_AUTHENTICATED("GLOBAL-403", "권한이 없습니다."), } enum class UserExceptionCode( override val code: String, override val message: String ) : ApiExceptionCode { - USER_NOT_FOUND("USER-001", "사용자를 찾을 수 없습니다."), - EMAIL_ALREADY_EXISTS("USER-002", "이미 존재하는 이메일입니다."), - INVALID_TOKEN("USER-003", "유효하지 않은 토큰입니다."), - USER_NOT_ACTIVE("USER-004", "이메일 인증이 완료되지 않은 사용자입니다."), - USER_ALREADY_ACTIVE("USER-005", "이미 이메일 인증이 완료된 사용자입니다."), - INVALID_REFRESH_TOKEN("USER-006", "유효하지 않은 리프레시 토큰입니다."), + USER_NOT_FOUND("USER-100", "사용자를 찾을 수 없습니다."), + INVALID_TOKEN("USER-101", "유효하지 않은 토큰입니다."), + INVALID_REFRESH_TOKEN("USER-002", "유효하지 않은 리프레시 토큰입니다."), + USER_NOT_ACTIVE("USER-103", "이메일 인증이 완료되지 않은 사용자입니다."), + + EMAIL_ALREADY_EXISTS("USER-201", "이미 존재하는 이메일입니다."), + USER_ALREADY_ACTIVE("USER-202", "이미 이메일 인증이 완료된 사용자입니다."), +} + +enum class SessionExceptionCode( + override val code: String, + override val message: String +) : ApiExceptionCode { + SESSION_ALREADY_STOPPED("SESSION-101", "이미 종료된 세션입니다."), + SESSION_NOT_STOPPED("SESSION-102", "종료되지 않은 세션입니다."), + SESSION_NOT_RESUMED("SESSION-103", "일시정지 상태가 아닌 세션입니다."), + SESSION_NOT_PAUSED("SESSION-104", "재개 상태가 아닌 세션입니다."), + SESSION_NOT_ACTIVE("SESSION-105", "활성 상태가 아닌 세션입니다."), } \ No newline at end of file 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 index c42263c..6d5ca73 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/exception/GlobalExceptionHandler.kt @@ -6,7 +6,7 @@ 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.MissingServletRequestParameterException +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 @@ -58,8 +58,8 @@ class GlobalExceptionHandler { return ApiResponse.failure(ApiException(GlobalExceptionCode.VALIDATION_FAILED, errors, ex)) } - @ExceptionHandler(MissingServletRequestParameterException::class) - fun handleMissingParameter(ex: MissingServletRequestParameterException): ApiResponse { + @ExceptionHandler(MissingRequestValueException::class) + fun handleMissingParameter(ex: MissingRequestValueException): ApiResponse { return ApiResponse.failure(ApiException(GlobalExceptionCode.MISSING_PARAMETER, ex)) } 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 index de642b5..b72be70 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetails.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/CustomUserDetails.kt @@ -1,6 +1,6 @@ package com.github.kusitms_bugi.global.security -import com.github.kusitms_bugi.domain.user.domain.User +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 From 7b93d2f0a6d2fec122a0376fb14ccb1529543f93 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Sat, 8 Nov 2025 00:03:43 +0900 Subject: [PATCH 08/29] =?UTF-8?q?feat(session):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/application/SessionService.kt | 35 +++++++++++++------ .../session/infrastructure/jpa/Session.kt | 3 +- .../dto/response/GetSessionReportResponse.kt | 7 ++-- 3 files changed, 30 insertions(+), 15 deletions(-) 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 index 34e08a5..c9f04fe 100644 --- 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 @@ -29,11 +29,10 @@ class SessionService( .takeIf { it == SessionStatus.STOPPED } ?: throw ApiException(SessionExceptionCode.SESSION_NOT_STOPPED) - val totalActiveTimeSeconds = calculateActiveTime(session) - return GetSessionReportResponse( - sessionId = session.id, - totalActiveTimeSeconds = totalActiveTimeSeconds + totalSeconds = calculateTotalSeconds(session), + goodSeconds = calculateGoodSeconds(session), + score = 0 ) } @@ -121,19 +120,19 @@ class SessionService( } } -private fun calculateActiveTime(session: Session): Long { - val history = session.statusHistory.sortedBy { it.timestamp } +private fun calculateTotalSeconds(session: Session): Long { var totalSeconds = 0L var activeStartTime: LocalDateTime? = null - history.forEach { event -> - when (event.status) { + session.statusHistory.forEach { + when (it.status) { SessionStatus.STARTED, SessionStatus.RESUMED -> { - activeStartTime = event.timestamp + activeStartTime = it.timestamp } + SessionStatus.PAUSED, SessionStatus.STOPPED -> { activeStartTime?.let { start -> - totalSeconds += ChronoUnit.SECONDS.between(start, event.timestamp) + totalSeconds += ChronoUnit.SECONDS.between(start, it.timestamp) activeStartTime = null } } @@ -142,3 +141,19 @@ private fun calculateActiveTime(session: Session): Long { return totalSeconds } + +private fun calculateGoodSeconds(session: Session): Long { + var totalSeconds = 0.0 + var previousMetric: SessionMetric? = null + + session.metrics.forEach { + previousMetric?.let { metric -> + if (it.score <= 1.2) { + totalSeconds += ChronoUnit.MILLIS.between(metric.timestamp, it.timestamp) + } + } + previousMetric = it + } + + return (totalSeconds / 1_000).toLong() +} 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 index 4dc61e9..452eb7a 100644 --- 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 @@ -26,6 +26,7 @@ class Session( @JoinColumn(name = "user_id", nullable = false) override var user: User, + @OrderBy("timestamp ASC") @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) override var statusHistory: MutableList = mutableListOf(), @@ -33,7 +34,7 @@ class Session( override var metrics: MutableList = mutableListOf() ) : BaseEntity(), SessionField { - fun lastStatus(): SessionStatus? = statusHistory.maxByOrNull { it.timestamp }?.status + fun lastStatus(): SessionStatus? = statusHistory.lastOrNull()?.status } @Entity 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 index 1d89302..6083b1b 100644 --- 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 @@ -1,8 +1,7 @@ package com.github.kusitms_bugi.domain.session.presentation.dto.response -import java.util.* - data class GetSessionReportResponse( - val sessionId: UUID, - val totalActiveTimeSeconds: Long + val totalSeconds: Long, + val goodSeconds: Long, + val score: Int ) From c64e3b9c683530a10419d6a74c8b61aed6eec0cb Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Sat, 8 Nov 2025 10:41:23 +0900 Subject: [PATCH 09/29] =?UTF-8?q?fix(session):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/application/SessionService.kt | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) 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 index c9f04fe..56fb7a2 100644 --- 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 @@ -146,14 +146,40 @@ private fun calculateGoodSeconds(session: Session): Long { var totalSeconds = 0.0 var previousMetric: SessionMetric? = null - session.metrics.forEach { - previousMetric?.let { metric -> - if (it.score <= 1.2) { - totalSeconds += ChronoUnit.MILLIS.between(metric.timestamp, it.timestamp) + session.metrics.forEach { metric -> + previousMetric?.let { prev -> + if (metric.score <= 1.2) { + val duration = ChronoUnit.MILLIS.between(prev.timestamp, metric.timestamp) + val pausedDuration = calculatePausedDuration(session, prev.timestamp, metric.timestamp) + totalSeconds += (duration - pausedDuration) } } - previousMetric = it + previousMetric = metric } return (totalSeconds / 1_000).toLong() } + +private fun calculatePausedDuration(session: Session, start: LocalDateTime, end: LocalDateTime): Long { + var pausedMillis = 0L + var pauseStartTime: LocalDateTime? = null + + session.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 pausedMillis +} From 5ef3d4358c6cf047f96e44a819555b0727c5c8a5 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Sat, 15 Nov 2025 16:06:52 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat(score):=20=EC=A0=90=EC=88=98=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 7 + .../dashboard/application/ScoreService.kt | 205 ++++++++++++++++++ .../session/application/SessionService.kt | 77 +------ .../domain/session/domain/Session.kt | 1 + .../session/infrastructure/jpa/Session.kt | 91 +++++++- .../dto/request/SaveMetricsRequest.kt | 4 + .../db/migration/V5__add_score_to_session.sql | 1 + 7 files changed, 315 insertions(+), 71 deletions(-) create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/ScoreService.kt create mode 100644 src/main/resources/db/migration/V5__add_score_to_session.sql diff --git a/build.gradle.kts b/build.gradle.kts index ca16de2..5aef0e6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { security() documentation() other() + testing() } fun DependencyHandlerScope.kotlin() { @@ -72,3 +73,9 @@ 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/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..4c0d589 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/ScoreService.kt @@ -0,0 +1,205 @@ +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.0) "good" else "bad" + + for (i in 1 until activeMetrics.size) { + val currentSide = if (activeMetrics[i].score < 3.0) "good" else "bad" + if (currentSide != previousSide) { + flips++ + previousSide = currentSide + } + } + + return flips + } + + private fun extractStreaks( + activeMetrics: List, + session: Session, + condition: (Double) -> Boolean + ): List { + if (activeMetrics.isEmpty()) return emptyList() + + 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(session, start, metric.timestamp) + if (activeDuration > 0) { + streaks.add(activeDuration) + } + streakStartTime = null + } + } + } + + streakStartTime?.let { start -> + val lastMetric = activeMetrics.last() + val activeDuration = calculateActiveDuration(session, start, lastMetric.timestamp) + if (activeDuration > 0) { + streaks.add(activeDuration) + } + } + + return streaks + } + + private fun calculateActiveDuration(session: Session, start: LocalDateTime, end: LocalDateTime): Double { + val duration = Duration.between(start, end) + val pausedDuration = session.calculatePausedDuration(start, end) + return duration.minus(pausedDuration).toMillis() / 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 <= 2.0 } + 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 >= 5.0 } + 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) + } +} 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 index 56fb7a2..ef7c419 100644 --- 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 @@ -14,12 +14,11 @@ import com.github.kusitms_bugi.global.exception.SessionExceptionCode import org.springframework.security.access.prepost.PreAuthorize import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.LocalDateTime -import java.time.temporal.ChronoUnit @Service class SessionService( - private val sessionRepository: SessionRepository + private val sessionRepository: SessionRepository, + private val scoreService: com.github.kusitms_bugi.domain.dashboard.application.ScoreService ) { @Transactional(readOnly = true) @@ -30,9 +29,9 @@ class SessionService( ?: throw ApiException(SessionExceptionCode.SESSION_NOT_STOPPED) return GetSessionReportResponse( - totalSeconds = calculateTotalSeconds(session), - goodSeconds = calculateGoodSeconds(session), - score = 0 + totalSeconds = session.calculateTotalActiveSeconds(), + goodSeconds = session.calculateGoodSeconds(), + score = scoreService.calculateScoreFromSession(session) ) } @@ -98,6 +97,8 @@ class SessionService( ) ) + session.score = scoreService.calculateScoreFromSession(session) + sessionRepository.save(session) } @@ -119,67 +120,3 @@ class SessionService( sessionRepository.save(session) } } - -private fun calculateTotalSeconds(session: Session): Long { - var totalSeconds = 0L - var activeStartTime: LocalDateTime? = null - - session.statusHistory.forEach { - when (it.status) { - SessionStatus.STARTED, SessionStatus.RESUMED -> { - activeStartTime = it.timestamp - } - - SessionStatus.PAUSED, SessionStatus.STOPPED -> { - activeStartTime?.let { start -> - totalSeconds += ChronoUnit.SECONDS.between(start, it.timestamp) - activeStartTime = null - } - } - } - } - - return totalSeconds -} - -private fun calculateGoodSeconds(session: Session): Long { - var totalSeconds = 0.0 - var previousMetric: SessionMetric? = null - - session.metrics.forEach { metric -> - previousMetric?.let { prev -> - if (metric.score <= 1.2) { - val duration = ChronoUnit.MILLIS.between(prev.timestamp, metric.timestamp) - val pausedDuration = calculatePausedDuration(session, prev.timestamp, metric.timestamp) - totalSeconds += (duration - pausedDuration) - } - } - previousMetric = metric - } - - return (totalSeconds / 1_000).toLong() -} - -private fun calculatePausedDuration(session: Session, start: LocalDateTime, end: LocalDateTime): Long { - var pausedMillis = 0L - var pauseStartTime: LocalDateTime? = null - - session.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 pausedMillis -} 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 index 535dbc3..b419e76 100644 --- 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 @@ -15,6 +15,7 @@ interface SessionField : BaseField { var user: User var statusHistory: MutableList var metrics: MutableList + var score: Int? } interface SessionStatusHistoryField : BaseField { 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 index 452eb7a..be7ac1d 100644 --- 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 @@ -9,7 +9,9 @@ import com.github.kusitms_bugi.global.entity.BaseEntity import jakarta.persistence.* 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") @@ -31,10 +33,97 @@ class Session( override var statusHistory: MutableList = mutableListOf(), @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) - override var metrics: MutableList = mutableListOf() + override var metrics: MutableList = mutableListOf(), + + @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() + + activeMetrics.zipWithNext().forEach { (current, next) -> + val level = current.score.toInt().coerceIn(1, 6) + val duration = Duration.between(current.timestamp, next.timestamp) + val pausedDuration = calculatePausedDuration(current.timestamp, next.timestamp) + val activeDuration = duration.minus(pausedDuration).toMillis() + + if (activeDuration > 0) { + levelDurations[level] = levelDurations.getValue(level) + activeDuration + } + } + + return levelDurations + } + + fun calculateGoodSeconds(): Long { + return metrics.zipWithNext() + .filter { (_, current) -> current.score <= 1.2 } + .sumOf { (prev, current) -> + val duration = ChronoUnit.MILLIS.between(prev.timestamp, current.timestamp) + val pausedDuration = calculatePausedDuration(prev.timestamp, current.timestamp).toMillis() + duration - pausedDuration + } / 1_000 + } } @Entity 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 index a5295eb..34c752a 100644 --- 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 @@ -1,5 +1,7 @@ package com.github.kusitms_bugi.domain.session.presentation.dto.request +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min import java.time.LocalDateTime import java.util.* @@ -9,6 +11,8 @@ data class SaveMetricsRequest( ) data class MetricData( + @field:Min(value = 1, message = "점수는 최소 1이어야 합니다.") + @field:Max(value = 6, message = "점수는 최대 6이어야 합니다.") val score: Double, val timestamp: LocalDateTime ) 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; From 8375ad4b5e16ea61d687b5a0db715fe1c62ac3d0 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Tue, 18 Nov 2025 02:15:34 +0900 Subject: [PATCH 11/29] =?UTF-8?q?feat(api):=20API=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/application/ScoreService.kt | 8 ++- .../dashboard/presentation/DashboardApi.kt | 57 +++++++++++++++++++ .../presentation/DashboardController.kt | 52 +++++++++++++++++ .../dto/request/GetPeriodRequest.kt | 24 ++++++++ .../dto/response/AttendanceResponse.kt | 13 +++++ .../dto/response/AverageScoreResponse.kt | 9 +++ .../dto/response/HighlightResponse.kt | 12 ++++ .../dto/response/LevelResponse.kt | 15 +++++ .../dto/response/PostureGraphResponse.kt | 13 +++++ .../dto/response/PosturePatternResponse.kt | 20 +++++++ .../session/application/SessionService.kt | 3 +- 11 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/DashboardApi.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/DashboardController.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/AttendanceResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/AverageScoreResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/HighlightResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/LevelResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/PostureGraphResponse.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/PosturePatternResponse.kt 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 index 4c0d589..f49f9df 100644 --- 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 @@ -5,7 +5,11 @@ 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.* +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round @Service class ScoreService { @@ -202,4 +206,4 @@ class ScoreService { 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..903b407 --- /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.GetPeriodRequest +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: GetPeriodRequest + ): ApiResponse + + @Operation(summary = "레벨 도달 현황 조회") + @GetMapping("/level") + fun getLevel( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse + + @Operation(summary = "바른 자세 점수 그래프 조회") + @GetMapping("/posture-graph") + fun getPostureGraph( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated request: GetPeriodRequest + ): ApiResponse + + @Operation(summary = "하이라이트 조회") + @GetMapping("/highlight") + fun getHighlight( + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated request: GetPeriodRequest + ): 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..b0a25e1 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/DashboardController.kt @@ -0,0 +1,52 @@ +package com.github.kusitms_bugi.domain.dashboard.presentation + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetPeriodRequest +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 : DashboardApi { + + override fun getAverageScore( + @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse { + TODO("Not yet implemented") + } + + override fun getAttendance( + @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated request: GetPeriodRequest + ): ApiResponse { + TODO("Not yet implemented") + } + + override fun getLevel( + @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse { + TODO("Not yet implemented") + } + + override fun getPostureGraph( + @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated request: GetPeriodRequest + ): ApiResponse { + TODO("Not yet implemented") + } + + override fun getHighlight( + @AuthenticationPrincipal userDetails: CustomUserDetails, + @Validated request: GetPeriodRequest + ): ApiResponse { + TODO("Not yet implemented") + } + + override fun getPosturePattern( + @AuthenticationPrincipal userDetails: CustomUserDetails + ): ApiResponse { + TODO("Not yet implemented") + } +} diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt new file mode 100644 index 0000000..c9f1374 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt @@ -0,0 +1,24 @@ +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 GetPeriodRequest( + @field:Schema(description = "조회 기간") + @field:NotNull(message = "조회 기간은 필수입니다") + var period: Period, + + @field:Schema(description = "조회 년도") + @field:NotNull(message = "년도는 필수입니다") + var year: Int, + + @field:Schema(description = "조회 월") + var month: Int? = null +) { + enum class Period { + MONTHLY, YEARLY + } +} 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..8600e4f --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/response/AttendanceResponse.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 AttendanceResponse( + @field:Schema( + description = "출석 데이터", + example = "{\"2025-01-01\": 3, \"2025-01-02\": 5, \"2025-01-03\": 2}" + ) + val attendances: Map +) 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 index ef7c419..b3d30d5 100644 --- 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 @@ -1,5 +1,6 @@ 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.SessionRepository import com.github.kusitms_bugi.domain.session.domain.SessionStatus import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session @@ -18,7 +19,7 @@ import org.springframework.transaction.annotation.Transactional @Service class SessionService( private val sessionRepository: SessionRepository, - private val scoreService: com.github.kusitms_bugi.domain.dashboard.application.ScoreService + private val scoreService: ScoreService ) { @Transactional(readOnly = true) From 5cf36d0709b9b484aced44c1445d53417c83dbdc Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Wed, 19 Nov 2025 23:55:01 +0900 Subject: [PATCH 12/29] =?UTF-8?q?feat(user):=20=EB=82=B4=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AuthService.kt} | 18 +++++-- .../domain/auth/presentation/AuthApi.kt | 48 +++++++++++++++++++ .../auth/presentation/AuthController.kt | 47 ++++++++++++++++++ .../dto/request/CheckEmailDuplicateRequest.kt | 2 +- .../presentation/dto/request/LoginRequest.kt | 2 +- .../dto/request/RefreshTokenRequest.kt | 2 +- .../request/ResendVerificationEmailRequest.kt | 2 +- .../presentation/dto/request/SignupRequest.kt | 2 +- .../dto/request/VerifyEmailRequest.kt | 2 +- .../response/CheckEmailDuplicateResponse.kt | 2 +- .../dto/response/LoginResponse.kt | 2 +- .../dto/response/RefreshTokenResponse.kt | 2 +- .../dto/response/SignupResponse.kt | 2 +- .../domain/user/presentation/UserApi.kt | 45 +++++------------ .../user/presentation/UserController.kt | 39 +++------------ .../dto/response/MyProfileResponse.kt | 15 ++++++ 16 files changed, 152 insertions(+), 80 deletions(-) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user/application/UserService.kt => auth/application/AuthService.kt} (78%) create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/AuthApi.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/AuthController.kt rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/request/CheckEmailDuplicateRequest.kt (82%) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/request/LoginRequest.kt (85%) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/request/RefreshTokenRequest.kt (74%) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/request/ResendVerificationEmailRequest.kt (88%) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/request/SignupRequest.kt (94%) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/request/VerifyEmailRequest.kt (72%) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/response/CheckEmailDuplicateResponse.kt (54%) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/response/LoginResponse.kt (59%) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/response/RefreshTokenResponse.kt (60%) rename src/main/kotlin/com/github/kusitms_bugi/domain/{user => auth}/presentation/dto/response/SignupResponse.kt (84%) create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/MyProfileResponse.kt diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/application/UserService.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/application/AuthService.kt similarity index 78% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/application/UserService.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/application/AuthService.kt index 7b9e3a4..2c84857 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/application/UserService.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/application/AuthService.kt @@ -1,8 +1,18 @@ -package com.github.kusitms_bugi.domain.user.application +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.domain.user.presentation.dto.request.* -import com.github.kusitms_bugi.domain.user.presentation.dto.response.* import com.github.kusitms_bugi.global.exception.ApiException import com.github.kusitms_bugi.global.exception.UserExceptionCode import com.github.kusitms_bugi.global.mail.EmailService @@ -13,7 +23,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service -class UserService( +class AuthService( private val emailService: EmailService, private val userRepository: UserRepository, private val passwordEncoder: PasswordEncoder, 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/user/presentation/dto/request/CheckEmailDuplicateRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/CheckEmailDuplicateRequest.kt similarity index 82% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/CheckEmailDuplicateRequest.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/CheckEmailDuplicateRequest.kt index 9fa1ce7..ff1cd42 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/CheckEmailDuplicateRequest.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/CheckEmailDuplicateRequest.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.request +package com.github.kusitms_bugi.domain.auth.presentation.dto.request import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/LoginRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/LoginRequest.kt similarity index 85% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/LoginRequest.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/LoginRequest.kt index 028e50b..03bae75 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/LoginRequest.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/LoginRequest.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.request +package com.github.kusitms_bugi.domain.auth.presentation.dto.request import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/RefreshTokenRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/RefreshTokenRequest.kt similarity index 74% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/RefreshTokenRequest.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/RefreshTokenRequest.kt index 9989889..eb16baa 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/RefreshTokenRequest.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/RefreshTokenRequest.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.request +package com.github.kusitms_bugi.domain.auth.presentation.dto.request import jakarta.validation.constraints.NotBlank diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/ResendVerificationEmailRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/ResendVerificationEmailRequest.kt similarity index 88% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/ResendVerificationEmailRequest.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/ResendVerificationEmailRequest.kt index 9ed8913..c1601f1 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/ResendVerificationEmailRequest.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/ResendVerificationEmailRequest.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.request +package com.github.kusitms_bugi.domain.auth.presentation.dto.request import com.github.kusitms_bugi.global.validator.AllowedOrigin import jakarta.validation.constraints.Email diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/SignupRequest.kt similarity index 94% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/SignupRequest.kt index 5a7b141..d175f4b 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/SignupRequest.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/SignupRequest.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.request +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 diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/VerifyEmailRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/VerifyEmailRequest.kt similarity index 72% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/VerifyEmailRequest.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/VerifyEmailRequest.kt index c8e04f3..68bcc46 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/request/VerifyEmailRequest.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/request/VerifyEmailRequest.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.request +package com.github.kusitms_bugi.domain.auth.presentation.dto.request import jakarta.validation.constraints.NotBlank diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/CheckEmailDuplicateResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/CheckEmailDuplicateResponse.kt similarity index 54% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/CheckEmailDuplicateResponse.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/CheckEmailDuplicateResponse.kt index 47ac723..b2e8277 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/CheckEmailDuplicateResponse.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/CheckEmailDuplicateResponse.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.response +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/user/presentation/dto/response/LoginResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/LoginResponse.kt similarity index 59% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/LoginResponse.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/LoginResponse.kt index 6f208b9..3527a1b 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/LoginResponse.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/LoginResponse.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.response +package com.github.kusitms_bugi.domain.auth.presentation.dto.response data class LoginResponse( val accessToken: String, diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/RefreshTokenResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/RefreshTokenResponse.kt similarity index 60% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/RefreshTokenResponse.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/RefreshTokenResponse.kt index eceec02..a92b7cb 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/RefreshTokenResponse.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/RefreshTokenResponse.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.response +package com.github.kusitms_bugi.domain.auth.presentation.dto.response data class RefreshTokenResponse( val accessToken: String, diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/SignupResponse.kt similarity index 84% rename from src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/SignupResponse.kt index 803caa4..d86f03b 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/user/presentation/dto/response/SignupResponse.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/auth/presentation/dto/response/SignupResponse.kt @@ -1,4 +1,4 @@ -package com.github.kusitms_bugi.domain.user.presentation.dto.response +package com.github.kusitms_bugi.domain.auth.presentation.dto.response import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User import java.util.* 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 index f7dd656..9af36c9 100644 --- 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 @@ -1,43 +1,22 @@ package com.github.kusitms_bugi.domain.user.presentation -import com.github.kusitms_bugi.domain.user.presentation.dto.request.* -import com.github.kusitms_bugi.domain.user.presentation.dto.response.CheckEmailDuplicateResponse -import com.github.kusitms_bugi.domain.user.presentation.dto.response.LoginResponse -import com.github.kusitms_bugi.domain.user.presentation.dto.response.RefreshTokenResponse -import com.github.kusitms_bugi.domain.user.presentation.dto.response.SignupResponse +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.validation.annotation.Validated -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping -@Tag(name = "인증") -@RequestMapping("/auth") +@Tag(name = "유저") +@RequestMapping("/users") interface UserApi { - @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 + @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 index e47358e..822675d 100644 --- 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 @@ -1,42 +1,15 @@ package com.github.kusitms_bugi.domain.user.presentation -import com.github.kusitms_bugi.domain.user.application.UserService -import com.github.kusitms_bugi.domain.user.presentation.dto.request.* -import com.github.kusitms_bugi.domain.user.presentation.dto.response.CheckEmailDuplicateResponse -import com.github.kusitms_bugi.domain.user.presentation.dto.response.LoginResponse -import com.github.kusitms_bugi.domain.user.presentation.dto.response.RefreshTokenResponse -import com.github.kusitms_bugi.domain.user.presentation.dto.response.SignupResponse +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( - private val userService: UserService -) : UserApi { +class UserController : UserApi { - override fun checkEmailDuplicate(request: CheckEmailDuplicateRequest): ApiResponse { - return ApiResponse.success(userService.checkEmailDuplicate(request)) - } - - override fun signup(request: SignupRequest): ApiResponse { - return ApiResponse.success(userService.signup(request)) - } - - override fun verifyEmail(request: VerifyEmailRequest): ApiResponse { - userService.verifyEmail(request) - return ApiResponse.success() - } - - override fun resendVerificationEmail(request: ResendVerificationEmailRequest): ApiResponse { - userService.resendVerificationEmail(request) - return ApiResponse.success() - } - - override fun login(request: LoginRequest): ApiResponse { - return ApiResponse.success(userService.login(request)) - } - - override fun refreshToken(request: RefreshTokenRequest): ApiResponse { - return ApiResponse.success(userService.refreshToken(request)) + 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 From 1e5092191413a44e83a78ee562470cd7561fe6c9 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Thu, 20 Nov 2025 02:28:37 +0900 Subject: [PATCH 13/29] =?UTF-8?q?feat(user):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AttendanceService.kt | 190 ++++++++++++++++++ .../application/AverageScoreService.kt | 61 ++++++ .../dashboard/application/HighlightService.kt | 86 ++++++++ .../dashboard/application/LevelService.kt | 86 ++++++++ .../application/PostureGraphService.kt | 89 ++++++++ .../application/PosturePatternService.kt | 115 +++++++++++ .../presentation/DashboardController.kt | 22 +- .../dto/request/GetPeriodRequest.kt | 2 +- .../dto/response/AttendanceResponse.kt | 26 ++- .../session/infrastructure/jpa/Session.kt | 12 +- .../jpa/SessionJpaRepository.kt | 11 +- 11 files changed, 681 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AttendanceService.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AverageScoreService.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/HighlightService.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/LevelService.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PostureGraphService.kt create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PosturePatternService.kt 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..2cacc91 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AttendanceService.kt @@ -0,0 +1,190 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetPeriodRequest +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.AttendanceResponse +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session +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 + +@Service +@Transactional(readOnly = true) +class AttendanceService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getAttendance(user: User, request: GetPeriodRequest): AttendanceResponse { + val (startDate, endDate) = when (request.period) { + GetPeriodRequest.Period.MONTHLY -> { + val yearMonth = YearMonth.of(request.year, request.month ?: 1) + yearMonth.atDay(1).atStartOfDay() to yearMonth.atEndOfMonth().atTime(23, 59, 59) + } + GetPeriodRequest.Period.YEARLY -> { + return AttendanceResponse( + attendances = emptyMap(), + title = "", + content1 = "", + content2 = "", + subContent = "" + ) + } + GetPeriodRequest.Period.WEEKLY -> { + return AttendanceResponse( + attendances = emptyMap(), + title = "", + content1 = "", + content2 = "", + subContent = "" + ) + } + } + + val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween(user, startDate, endDate) + + val attendances = sessions + .groupBy { it.createdAt.toLocalDate() } + .mapValues { (_, sessionsOnDate) -> + sessionsOnDate.sumOf { session -> + (session.calculateTotalActiveSeconds() / 60.0).toInt() + } + } + + val recentSevenDaysStart = LocalDate.now().minusDays(6) + val previousSevenDaysStart = LocalDate.now().minusDays(13) + val previousSevenDaysEnd = LocalDate.now().minusDays(7) + + val recentSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + recentSevenDaysStart.atStartOfDay(), + LocalDate.now().atTime(23, 59, 59) + ) + + val previousSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + previousSevenDaysStart.atStartOfDay(), + previousSevenDaysEnd.atTime(23, 59, 59) + ) + + val title = calculateTitle(recentSessions, previousSessions) + val content1 = calculateContent1(user, recentSessions) + val content2 = calculateContent2(user, recentSessions) + val subContent = calculateSubContent(recentSessions) + + return AttendanceResponse( + attendances = attendances, + title = title, + content1 = content1, + content2 = content2, + subContent = subContent + ) + } + + private fun calculateTitle(recentSessions: List, previousSessions: List): String { + val recentBadTime = calculateTotalBadPostureTime(recentSessions) + val previousBadTime = calculateTotalBadPostureTime(previousSessions) + val recentGoodRatio = calculateGoodPostureRatio(recentSessions) + val previousGoodRatio = calculateGoodPostureRatio(previousSessions) + + val badTimeIncreased = recentBadTime > previousBadTime + val goodRatioDecreased = recentGoodRatio < previousGoodRatio + + return when { + !badTimeIncreased && !goodRatioDecreased -> "잘하고 있어요!" + badTimeIncreased && goodRatioDecreased -> "주의가 필요해요!" + else -> "조금만 더 힘내봐요!" + } + } + + private fun calculateContent1(user: User, recentSessions: List): String { + val recentSevenDaysStart = LocalDate.now().minusDays(6) + val allUsersSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + recentSevenDaysStart.atStartOfDay(), + LocalDate.now().atTime(23, 59, 59) + ) + + val myAverageScore = recentSessions + .mapNotNull { it.score?.toDouble() } + .takeIf { it.isNotEmpty() } + ?.average() ?: 50.0 + + val allScores = allUsersSessions + .mapNotNull { it.score?.toDouble() } + .takeIf { it.isNotEmpty() } ?: listOf(50.0) + + val higherCount = allScores.count { score -> score < myAverageScore } + val percentile = if (allScores.isNotEmpty()) { + round((higherCount.toDouble() / allScores.size) * 100).toInt() + } else { + 50 + } + + return "전체 사용자 중, 당신의 자세는 상위 ${percentile}%예요" + } + + private fun calculateContent2(user: User, recentSessions: List): String { + val recentSevenDaysStart = LocalDate.now().minusDays(6) + val allUsersSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + recentSevenDaysStart.atStartOfDay(), + LocalDate.now().atTime(23, 59, 59) + ) + + val myGoodRatio = calculateGoodPostureRatio(recentSessions) + val allUsersGoodRatio = calculateGoodPostureRatio(allUsersSessions) + + val difference = round(myGoodRatio - allUsersGoodRatio).toInt() + + return when { + difference > 0 -> "다른 사용자보다 바른 자세 비율이 ${difference}% 높아요" + difference < 0 -> "다른 사용자보다 거북목이 평균 ${-difference}% 더 적어요" + else -> "다른 사용자와 비슷한 자세 비율이에요" + } + } + + private fun calculateSubContent(sessions: List): String { + val averageLevel = sessions + .flatMap { session -> + val levelDurations = session.getLevelDurations() + (1..6).flatMap { level -> + List((levelDurations[level] ?: 0L).toInt()) { level } + } + } + .takeIf { it.isNotEmpty() } + ?.average() ?: 3.0 + + return when { + averageLevel <= 1.5 -> "꼿꼿기린" + averageLevel <= 2.5 -> "쑥쑥기린" + averageLevel <= 3.5 -> "아기기린" + averageLevel <= 4.5 -> "꾸부정거부기" + else -> "뽀각거부기" + } + } + + private fun calculateTotalBadPostureTime(sessions: List): Long { + return sessions.sumOf { session -> + val levelDurations = session.getLevelDurations() + (levelDurations[4] ?: 0L) + (levelDurations[5] ?: 0L) + (levelDurations[6] ?: 0L) + } + } + + private fun calculateGoodPostureRatio(sessions: List): Double { + val totalDuration = sessions.sumOf { session -> + session.getLevelDurations().values.sum() + }.toDouble() + + if (totalDuration == 0.0) return 0.0 + + val goodDuration = sessions.sumOf { session -> + val levelDurations = session.getLevelDurations() + (levelDurations[1] ?: 0L) + (levelDurations[2] ?: 0L) + (levelDurations[3] ?: 0L) + } + + 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..c2057d8 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/AverageScoreService.kt @@ -0,0 +1,61 @@ +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.Session +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 +import kotlin.math.round + +@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.findByUserAndCreatedAtAfter(user, thirtyDaysAgo) + .takeIf { it.isNotEmpty() } + ?.mapNotNull { session -> calculateCompositeScore(session) } + ?.takeIf { it.isNotEmpty() } + ?.average() + ?.let { round(it).toInt().coerceIn(MIN_SCORE, MAX_SCORE) } + ?: DEFAULT_SCORE + + return AverageScoreResponse(score = averageScore) + } + + private fun calculateCompositeScore(session: Session): Double? { + val finalScore = session.score?.toDouble() ?: return null + val levelDurations = session.getLevelDurations() + val totalDuration = levelDurations.values.sum().toDouble() + + val pz = when { + totalDuration == 0.0 -> DEFAULT_PZ + else -> { + val goodLevelDuration = levelDurations[LEVEL_1].orZero() + levelDurations[LEVEL_2].orZero() + (goodLevelDuration / totalDuration) * 100.0 + } + } + + return (finalScore * FINAL_SCORE_WEIGHT) + (pz * PZ_WEIGHT) + } + + private fun Long?.orZero() = this ?: 0L + + companion object { + private const val ANALYSIS_PERIOD_DAYS = 30L + private const val FINAL_SCORE_WEIGHT = 0.65 + private const val PZ_WEIGHT = 0.35 + private const val DEFAULT_SCORE = 50 + private const val DEFAULT_PZ = 50.0 + private const val MIN_SCORE = 1 + private const val MAX_SCORE = 100 + private const val LEVEL_1 = 1 + private const val LEVEL_2 = 2 + } +} 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..df8d3a2 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/HighlightService.kt @@ -0,0 +1,86 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetPeriodRequest +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.HighlightResponse +import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session +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: GetPeriodRequest): HighlightResponse { + return when (request.period) { + GetPeriodRequest.Period.WEEKLY -> calculateWeeklyHighlight(user) + GetPeriodRequest.Period.MONTHLY -> calculateMonthlyHighlight(user, request.year, request.month ?: 1) + GetPeriodRequest.Period.YEARLY -> HighlightResponse(current = 0, previous = 0) + } + } + + 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 thisWeekSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + thisWeekStart.atStartOfDay(), + today.atTime(23, 59, 59) + ) + + val lastWeekSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + lastWeekStart.atStartOfDay(), + lastWeekEnd.atTime(23, 59, 59) + ) + + return HighlightResponse( + current = calculateAverageBadPostureMinutes(thisWeekSessions), + previous = calculateAverageBadPostureMinutes(lastWeekSessions) + ) + } + + private fun calculateMonthlyHighlight(user: User, year: Int, month: Int): HighlightResponse { + val thisMonth = YearMonth.of(year, month) + val lastMonth = thisMonth.minusMonths(1) + + val thisMonthSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + thisMonth.atDay(1).atStartOfDay(), + thisMonth.atEndOfMonth().atTime(23, 59, 59) + ) + + val lastMonthSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + lastMonth.atDay(1).atStartOfDay(), + lastMonth.atEndOfMonth().atTime(23, 59, 59) + ) + + return HighlightResponse( + current = calculateAverageBadPostureMinutes(thisMonthSessions), + previous = calculateAverageBadPostureMinutes(lastMonthSessions) + ) + } + + private fun calculateAverageBadPostureMinutes(sessions: List): Int { + if (sessions.isEmpty()) return 0 + + val totalBadPostureMillis = sessions.sumOf { session -> + val levelDurations = session.getLevelDurations() + (levelDurations[4] ?: 0L) + (levelDurations[5] ?: 0L) + (levelDurations[6] ?: 0L) + } + + val averageMillis = totalBadPostureMillis / sessions.size + return (averageMillis / 1000 / 60).toInt() + } +} 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..e56cab9 --- /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.Session +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 kotlin.math.pow + +@Service +@Transactional(readOnly = true) +class LevelService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getLevel(user: User): LevelResponse { + val sessions = sessionJpaRepository.findByUser(user) + val totalDistance = calculateTotalDistance(sessions) + 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 calculateTotalDistance(sessions: List): Double { + return sessions.sumOf { session -> + val levelDurations = session.getLevelDurations() + + 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 0.1, + 2 to 50.0, + 3 to 200.0, + 4 to 500.0, + 5 to 1500.0, + 6 to 3000.0 + ) + + 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..0045ef1 --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PostureGraphService.kt @@ -0,0 +1,89 @@ +package com.github.kusitms_bugi.domain.dashboard.application + +import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetPeriodRequest +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.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters +import kotlin.math.round + +@Service +@Transactional(readOnly = true) +class PostureGraphService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getPostureGraph(user: User, request: GetPeriodRequest): PostureGraphResponse { + val points = when (request.period) { + GetPeriodRequest.Period.WEEKLY -> calculateWeeklyData(user) + GetPeriodRequest.Period.MONTHLY -> { + val month = request.month ?: LocalDate.now().monthValue + calculateMonthlyData(user, request.year, month) + } + GetPeriodRequest.Period.YEARLY -> emptyMap() + } + + return PostureGraphResponse(points) + } + + private fun calculateWeeklyData(user: User): Map { + val now = LocalDate.now() + val weeks = (0 until WEEKS_COUNT).map { weekOffset -> + val weekStart = now.minusWeeks(weekOffset.toLong()) + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + weekStart + }.reversed() + + val startDate = weeks.first().atStartOfDay() + val endDate = now.atTime(23, 59, 59) + val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween(user, startDate, endDate) + + val sessionsByWeek = sessions.groupBy { session -> + session.createdAt.toLocalDate() + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + } + + return weeks.associateWith { weekStart -> + sessionsByWeek[weekStart] + ?.mapNotNull { it.score } + ?.takeIf { it.isNotEmpty() } + ?.average() + ?.let { round(it).toInt() } + ?: 0 + } + } + + private fun calculateMonthlyData(user: User, year: Int, month: Int): Map { + val yearMonth = java.time.YearMonth.of(year, month) + val daysInMonth = yearMonth.lengthOfMonth() + + val days = (1..daysInMonth).map { day -> + LocalDate.of(year, month, day) + } + + val startDate = yearMonth.atDay(1).atStartOfDay() + val endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59) + val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween(user, startDate, endDate) + + val sessionsByDay = sessions.groupBy { session -> + session.createdAt.toLocalDate() + } + + return days.associateWith { day -> + sessionsByDay[day] + ?.mapNotNull { it.score } + ?.takeIf { it.isNotEmpty() } + ?.average() + ?.let { round(it).toInt() } + ?: 0 + } + } + + companion object { + private const val WEEKS_COUNT = 20 + } +} 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..96fc7ca --- /dev/null +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/application/PosturePatternService.kt @@ -0,0 +1,115 @@ +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.Session +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 + +@Service +@Transactional(readOnly = true) +class PosturePatternService( + private val sessionJpaRepository: SessionJpaRepository +) { + + fun getPosturePattern(user: User): PosturePatternResponse { + val sevenDaysAgo = LocalDate.now().minusDays(6) + val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + sevenDaysAgo.atStartOfDay(), + LocalDate.now().atTime(23, 59, 59) + ) + + val worstTime = calculateWorstTime(sessions) + val worstDay = calculateWorstDay(sessions) + val recovery = calculateRecovery(sessions) + + return PosturePatternResponse( + worstTime = worstTime, + worstDay = worstDay, + recovery = recovery, + stretching = DEFAULT_STRETCHING + ) + } + + private fun calculateWorstTime(sessions: List): LocalTime { + val badPostureDurationByHour = mutableMapOf() + + sessions.forEach { session -> + val metrics = session.getActiveMetrics() + + metrics.zipWithNext().forEach { (current, next) -> + if (current.score >= 4.0) { + val hour = current.timestamp.hour + val durationMillis = java.time.Duration.between(current.timestamp, next.timestamp).toMillis() + val pausedDuration = session.calculatePausedDuration(current.timestamp, next.timestamp).toMillis() + val activeDuration = durationMillis - pausedDuration + + if (activeDuration > 0) { + badPostureDurationByHour[hour] = badPostureDurationByHour.getOrDefault(hour, 0L) + activeDuration + } + } + } + } + + val worstHour = badPostureDurationByHour.maxByOrNull { it.value }?.key ?: 12 + return LocalTime.of(worstHour, 0, 0) + } + + private fun calculateWorstDay(sessions: List): DayOfWeek { + val badPostureDurationByDay = mutableMapOf() + + sessions.forEach { session -> + val dayOfWeek = session.createdAt.dayOfWeek + val levelDurations = session.getLevelDurations() + val badPostureDuration = (levelDurations[4] ?: 0L) + + (levelDurations[5] ?: 0L) + + (levelDurations[6] ?: 0L) + + badPostureDurationByDay[dayOfWeek] = badPostureDurationByDay.getOrDefault(dayOfWeek, 0L) + badPostureDuration + } + + return badPostureDurationByDay.maxByOrNull { it.value }?.key ?: DayOfWeek.MONDAY + } + + private fun calculateRecovery(sessions: List): Int { + val recoveryTimes = mutableListOf() + + sessions.forEach { session -> + val metrics = session.getActiveMetrics() + var badPostureStartTime: java.time.LocalDateTime? = null + + metrics.forEach { metric -> + when { + metric.score >= 4.0 && badPostureStartTime == null -> { + badPostureStartTime = metric.timestamp + } + metric.score < 4.0 && badPostureStartTime != null -> { + val startTime = badPostureStartTime!! + val durationMillis = java.time.Duration.between(startTime, metric.timestamp).toMillis() + val pausedDuration = session.calculatePausedDuration(startTime, metric.timestamp).toMillis() + val activeDuration = durationMillis - pausedDuration + + if (activeDuration > 0) { + recoveryTimes.add(activeDuration) + } + badPostureStartTime = null + } + } + } + } + + if (recoveryTimes.isEmpty()) return 0 + + val averageRecoveryMillis = recoveryTimes.average() + return (averageRecoveryMillis / 1000 / 60).toInt() + } + + companion object { + private const val DEFAULT_STRETCHING = "목돌리기" + } +} 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 index b0a25e1..b7dfe40 100644 --- 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 @@ -1,5 +1,6 @@ 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.GetPeriodRequest import com.github.kusitms_bugi.domain.dashboard.presentation.dto.response.* import com.github.kusitms_bugi.global.response.ApiResponse @@ -9,44 +10,51 @@ import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.RestController @RestController -class DashboardController : DashboardApi { +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 { - TODO("Not yet implemented") + return ApiResponse.success(averageScoreService.getAverageScore(userDetails.user)) } override fun getAttendance( @AuthenticationPrincipal userDetails: CustomUserDetails, @Validated request: GetPeriodRequest ): ApiResponse { - TODO("Not yet implemented") + return ApiResponse.success(attendanceService.getAttendance(userDetails.user, request)) } override fun getLevel( @AuthenticationPrincipal userDetails: CustomUserDetails ): ApiResponse { - TODO("Not yet implemented") + return ApiResponse.success(levelService.getLevel(userDetails.user)) } override fun getPostureGraph( @AuthenticationPrincipal userDetails: CustomUserDetails, @Validated request: GetPeriodRequest ): ApiResponse { - TODO("Not yet implemented") + return ApiResponse.success(postureGraphService.getPostureGraph(userDetails.user, request)) } override fun getHighlight( @AuthenticationPrincipal userDetails: CustomUserDetails, @Validated request: GetPeriodRequest ): ApiResponse { - TODO("Not yet implemented") + return ApiResponse.success(highlightService.getHighlight(userDetails.user, request)) } override fun getPosturePattern( @AuthenticationPrincipal userDetails: CustomUserDetails ): ApiResponse { - TODO("Not yet implemented") + return ApiResponse.success(posturePatternService.getPosturePattern(userDetails.user)) } } diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt index c9f1374..f444365 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt @@ -19,6 +19,6 @@ data class GetPeriodRequest( var month: Int? = null ) { enum class Period { - MONTHLY, YEARLY + WEEKLY, MONTHLY, YEARLY } } 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 index 8600e4f..9f0ee97 100644 --- 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 @@ -9,5 +9,29 @@ data class AttendanceResponse( description = "출석 데이터", example = "{\"2025-01-01\": 3, \"2025-01-02\": 5, \"2025-01-03\": 2}" ) - val attendances: Map + 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/session/infrastructure/jpa/Session.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/Session.kt index be7ac1d..87a3a42 100644 --- 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 @@ -7,6 +7,8 @@ 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.Fetch +import org.hibernate.annotations.FetchMode import org.springframework.data.annotation.CreatedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.Duration @@ -15,14 +17,6 @@ import java.time.temporal.ChronoUnit @Entity @Table(name = "session") -@NamedEntityGraph( - name = "Session.withAll", - attributeNodes = [ - NamedAttributeNode("user"), - NamedAttributeNode("statusHistory"), - NamedAttributeNode("metrics") - ] -) class Session( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) @@ -30,9 +24,11 @@ class Session( @OrderBy("timestamp ASC") @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) + @Fetch(FetchMode.SUBSELECT) override var statusHistory: MutableList = mutableListOf(), @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) + @Fetch(FetchMode.SUBSELECT) override var metrics: MutableList = mutableListOf(), @Column(name = "score", nullable = true) 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 index 2fe5ec4..53826f2 100644 --- 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 @@ -1,11 +1,18 @@ package com.github.kusitms_bugi.domain.session.infrastructure.jpa +import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime import java.util.* interface SessionJpaRepository : JpaRepository { - - @EntityGraph("Session.withAll") + override fun findById(id: UUID): Optional + + fun findByUser(user: User): List + + fun findByUserAndCreatedAtAfter(user: User, createdAt: LocalDateTime): List + + fun findByUserAndCreatedAtBetween(user: User, startDate: LocalDateTime, endDate: LocalDateTime): List } \ No newline at end of file From 395af9d177b83e38999a70eef55561a7373000fb Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Thu, 20 Nov 2025 12:44:54 +0900 Subject: [PATCH 14/29] =?UTF-8?q?fix(jpa):=20lazy=20init=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/jpa/SessionJpaRepository.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 index 53826f2..1296733 100644 --- 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 @@ -1,18 +1,23 @@ package com.github.kusitms_bugi.domain.session.infrastructure.jpa import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User -import org.springframework.data.jpa.repository.EntityGraph 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 { - override fun findById(id: UUID): Optional + @Query("SELECT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.id = :id") + override fun findById(@Param("id") id: UUID): Optional - fun findByUser(user: User): List + @Query("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.user = :user") + fun findByUser(@Param("user") user: User): List - fun findByUserAndCreatedAtAfter(user: User, createdAt: LocalDateTime): List + @Query("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.user = :user AND s.createdAt > :createdAt") + fun findByUserAndCreatedAtAfter(@Param("user") user: User, @Param("createdAt") createdAt: LocalDateTime): List - fun findByUserAndCreatedAtBetween(user: User, startDate: LocalDateTime, endDate: LocalDateTime): List + @Query("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.user = :user AND s.createdAt BETWEEN :startDate AND :endDate") + fun findByUserAndCreatedAtBetween(@Param("user") user: User, @Param("startDate") startDate: LocalDateTime, @Param("endDate") endDate: LocalDateTime): List } \ No newline at end of file From b2d55d5e3a7cdd819d7b5b93a4648546b8d6243b Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Thu, 20 Nov 2025 13:01:42 +0900 Subject: [PATCH 15/29] =?UTF-8?q?fix(email):=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=A9=94=EC=9D=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/email-verification.html | 101 ++++++++++++++---- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html index 5600b48..a9fc83d 100644 --- a/src/main/resources/templates/email-verification.html +++ b/src/main/resources/templates/email-verification.html @@ -2,30 +2,85 @@ + - -
-
-
-

- 메일 인증 안내 -

-

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

- - 메일 인증 - -
-

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

-
-
-
+ + + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+

+ 메일 인증 안내 +

+
+ + + + + + +
+

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

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

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

+
+
+
+
\ No newline at end of file From 46192e9f7f4ecacb133f82f83ba11b2f40f3c882 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Thu, 20 Nov 2025 13:04:30 +0900 Subject: [PATCH 16/29] refactor(session): replace FetchMode.SUBSELECT with BatchSize for improved performance --- .../github/kusitms_bugi/domain/session/domain/Session.kt | 2 +- .../domain/session/infrastructure/jpa/Session.kt | 7 ++----- .../session/infrastructure/jpa/SessionJpaRepository.kt | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) 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 index b419e76..28685e7 100644 --- 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 @@ -14,7 +14,7 @@ enum class SessionStatus { interface SessionField : BaseField { var user: User var statusHistory: MutableList - var metrics: MutableList + var metrics: MutableSet var score: Int? } 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 index 87a3a42..eadda67 100644 --- 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 @@ -7,8 +7,6 @@ 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.Fetch -import org.hibernate.annotations.FetchMode import org.springframework.data.annotation.CreatedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.Duration @@ -24,12 +22,11 @@ class Session( @OrderBy("timestamp ASC") @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) - @Fetch(FetchMode.SUBSELECT) override var statusHistory: MutableList = mutableListOf(), + @OrderBy("timestamp ASC") @OneToMany(mappedBy = "session", cascade = [CascadeType.ALL], orphanRemoval = true) - @Fetch(FetchMode.SUBSELECT) - override var metrics: MutableList = mutableListOf(), + override var metrics: MutableSet = mutableSetOf(), @Column(name = "score", nullable = true) override var score: Int? = null 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 index 1296733..46c8cc9 100644 --- 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 @@ -9,7 +9,7 @@ import java.util.* interface SessionJpaRepository : JpaRepository { - @Query("SELECT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.id = :id") + @Query("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.id = :id") override fun findById(@Param("id") id: UUID): Optional @Query("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.user = :user") @@ -20,4 +20,4 @@ interface SessionJpaRepository : JpaRepository { @Query("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.user = :user AND s.createdAt BETWEEN :startDate AND :endDate") fun findByUserAndCreatedAtBetween(@Param("user") user: User, @Param("startDate") startDate: LocalDateTime, @Param("endDate") endDate: LocalDateTime): List -} \ No newline at end of file +} From 838f391100356a4f3486843fb51b0c3f70c426c9 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Thu, 20 Nov 2025 17:15:58 +0900 Subject: [PATCH 17/29] refactor(dashboard): simplify posture graph retrieval by removing period request --- .../application/PostureGraphService.kt | 67 ++++--------------- .../dashboard/presentation/DashboardApi.kt | 5 +- .../presentation/DashboardController.kt | 5 +- 3 files changed, 16 insertions(+), 61 deletions(-) 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 index 0045ef1..f0ee354 100644 --- 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 @@ -1,14 +1,11 @@ package com.github.kusitms_bugi.domain.dashboard.application -import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetPeriodRequest 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.DayOfWeek import java.time.LocalDate -import java.time.temporal.TemporalAdjusters import kotlin.math.round @Service @@ -17,63 +14,25 @@ class PostureGraphService( private val sessionJpaRepository: SessionJpaRepository ) { - fun getPostureGraph(user: User, request: GetPeriodRequest): PostureGraphResponse { - val points = when (request.period) { - GetPeriodRequest.Period.WEEKLY -> calculateWeeklyData(user) - GetPeriodRequest.Period.MONTHLY -> { - val month = request.month ?: LocalDate.now().monthValue - calculateMonthlyData(user, request.year, month) - } - GetPeriodRequest.Period.YEARLY -> emptyMap() - } - - return PostureGraphResponse(points) - } + fun getPostureGraph(user: User): PostureGraphResponse { + val today = LocalDate.now() + val startDate = today.minusDays(30) - private fun calculateWeeklyData(user: User): Map { - val now = LocalDate.now() - val weeks = (0 until WEEKS_COUNT).map { weekOffset -> - val weekStart = now.minusWeeks(weekOffset.toLong()) - .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - weekStart + val days = (0..30).map { offset -> + today.minusDays(offset.toLong()) }.reversed() - val startDate = weeks.first().atStartOfDay() - val endDate = now.atTime(23, 59, 59) - val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween(user, startDate, endDate) - - val sessionsByWeek = sessions.groupBy { session -> - session.createdAt.toLocalDate() - .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - } - - return weeks.associateWith { weekStart -> - sessionsByWeek[weekStart] - ?.mapNotNull { it.score } - ?.takeIf { it.isNotEmpty() } - ?.average() - ?.let { round(it).toInt() } - ?: 0 - } - } - - private fun calculateMonthlyData(user: User, year: Int, month: Int): Map { - val yearMonth = java.time.YearMonth.of(year, month) - val daysInMonth = yearMonth.lengthOfMonth() - - val days = (1..daysInMonth).map { day -> - LocalDate.of(year, month, day) - } - - val startDate = yearMonth.atDay(1).atStartOfDay() - val endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59) - val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween(user, startDate, endDate) + val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user, + startDate.atStartOfDay(), + today.atTime(23, 59, 59) + ) val sessionsByDay = sessions.groupBy { session -> session.createdAt.toLocalDate() } - return days.associateWith { day -> + val points = days.associateWith { day -> sessionsByDay[day] ?.mapNotNull { it.score } ?.takeIf { it.isNotEmpty() } @@ -81,9 +40,7 @@ class PostureGraphService( ?.let { round(it).toInt() } ?: 0 } - } - companion object { - private const val WEEKS_COUNT = 20 + return PostureGraphResponse(points) } } 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 index 903b407..22d1eb1 100644 --- 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 @@ -35,11 +35,10 @@ interface DashboardApi { @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails ): ApiResponse - @Operation(summary = "바른 자세 점수 그래프 조회") + @Operation(summary = "바른 자세 점수 그래프 조회 (최근 31일)") @GetMapping("/posture-graph") fun getPostureGraph( - @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails, - @Validated request: GetPeriodRequest + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails ): ApiResponse @Operation(summary = "하이라이트 조회") 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 index b7dfe40..6c6c7bb 100644 --- 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 @@ -39,10 +39,9 @@ class DashboardController( } override fun getPostureGraph( - @AuthenticationPrincipal userDetails: CustomUserDetails, - @Validated request: GetPeriodRequest + @AuthenticationPrincipal userDetails: CustomUserDetails ): ApiResponse { - return ApiResponse.success(postureGraphService.getPostureGraph(userDetails.user, request)) + return ApiResponse.success(postureGraphService.getPostureGraph(userDetails.user)) } override fun getHighlight( From 2028d171d0dea590630ef5587e669d43cf5537ca Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Fri, 21 Nov 2025 12:42:34 +0900 Subject: [PATCH 18/29] =?UTF-8?q?feat(auth):=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/application/AuthService.kt | 5 ++-- .../global/exception/ApiExceptionCode.kt | 13 +++++++-- .../global/response/ApiResponse.kt | 2 +- .../security/JwtAuthenticationFilter.kt | 29 ++++++++++++++----- .../global/security/JwtTokenProvider.kt | 10 +++---- .../resources/config/application-mail.yaml | 2 +- 6 files changed, 41 insertions(+), 20 deletions(-) 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 index 2c84857..fb4face 100644 --- 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 @@ -14,6 +14,7 @@ import com.github.kusitms_bugi.domain.auth.presentation.dto.response.SignupRespo 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 @@ -57,7 +58,7 @@ class AuthService( fun verifyEmail(request: VerifyEmailRequest) { request.token .takeIf { emailVerificationTokenProvider.validateToken(it) } - ?: throw ApiException(UserExceptionCode.INVALID_TOKEN) + ?: throw ApiException(AuthExceptionCode.INVALID_TOKEN) emailVerificationTokenProvider.getUserIdFromToken(request.token) .let { userRepository.findById(it) ?: throw ApiException(UserExceptionCode.USER_NOT_FOUND) } @@ -89,7 +90,7 @@ class AuthService( @Transactional(readOnly = true) fun refreshToken(request: RefreshTokenRequest): RefreshTokenResponse = request.refreshToken - .also { if (!jwtTokenProvider.validateRefreshToken(it)) throw ApiException(UserExceptionCode.INVALID_REFRESH_TOKEN) } + .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) } 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 index 27e4f2e..ca0b06e 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt @@ -23,14 +23,21 @@ enum class GlobalExceptionCode( 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", "사용자를 찾을 수 없습니다."), - INVALID_TOKEN("USER-101", "유효하지 않은 토큰입니다."), - INVALID_REFRESH_TOKEN("USER-002", "유효하지 않은 리프레시 토큰입니다."), - USER_NOT_ACTIVE("USER-103", "이메일 인증이 완료되지 않은 사용자입니다."), + USER_NOT_ACTIVE("USER-101", "이메일 인증이 완료되지 않은 사용자입니다."), EMAIL_ALREADY_EXISTS("USER-201", "이미 존재하는 이메일입니다."), USER_ALREADY_ACTIVE("USER-202", "이미 이메일 인증이 완료된 사용자입니다."), 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 index 6408d62..826db1b 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/response/ApiResponse.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/response/ApiResponse.kt @@ -31,7 +31,7 @@ data class ApiResponse( ) } - fun failure(exception: ApiException): ApiResponse { + fun failure(exception: ApiException): ApiResponse { return ApiResponse( timestamp = LocalDateTime.now(), success = false, 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 index 530c265..ab3557d 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtAuthenticationFilter.kt @@ -1,8 +1,12 @@ 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 @@ -11,7 +15,8 @@ import org.springframework.web.filter.OncePerRequestFilter @Component class JwtAuthenticationFilter( private val jwtTokenProvider: JwtTokenProvider, - private val userDetailsService: CustomUserDetailsService + private val userDetailsService: CustomUserDetailsService, + private val objectMapper: ObjectMapper ) : OncePerRequestFilter() { override fun doFilterInternal( @@ -19,14 +24,22 @@ class JwtAuthenticationFilter( response: HttpServletResponse, filterChain: FilterChain ) { - 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 } + 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) + 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? { 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 index 600804b..0296268 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtTokenProvider.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/security/JwtTokenProvider.kt @@ -1,6 +1,9 @@ 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 @@ -45,11 +48,6 @@ class JwtTokenProvider( return storedToken == refreshToken } - fun deleteRefreshToken(userId: UUID) { - val key = "refresh_token:$userId" - redisTemplate.delete(key) - } - private fun createToken(userId: UUID, validity: Long, tokenType: TokenType): String { val now = Date() @@ -72,6 +70,8 @@ class JwtTokenProvider( .build() .parseSignedClaims(token) true + } catch (_: ExpiredJwtException) { + throw ApiException(AuthExceptionCode.TOKEN_EXPIRED) } catch (_: Exception) { false } diff --git a/src/main/resources/config/application-mail.yaml b/src/main/resources/config/application-mail.yaml index 8a8ba5f..a92574c 100644 --- a/src/main/resources/config/application-mail.yaml +++ b/src/main/resources/config/application-mail.yaml @@ -8,7 +8,7 @@ spring: mail: smtp: auth: true - starttls: + ssl: enable: true required: true connectiontimeout: 5000 From 67e563599a86ebad546f983bab5b3a9b2e63f31a Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Fri, 21 Nov 2025 13:43:16 +0900 Subject: [PATCH 19/29] =?UTF-8?q?chore:=20=EB=9E=9C=EB=8D=A4=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=B9=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/PosturePatternService.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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 index 96fc7ca..02f8bf1 100644 --- 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 @@ -32,7 +32,7 @@ class PosturePatternService( worstTime = worstTime, worstDay = worstDay, recovery = recovery, - stretching = DEFAULT_STRETCHING + stretching = getRandomStretching(user) ) } @@ -109,7 +109,25 @@ class PosturePatternService( return (averageRecoveryMillis / 1000 / 60).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 const val DEFAULT_STRETCHING = "목돌리기" + private val STRETCHING_LIST = listOf( + "턱 당기기", + "목 측면 스트레칭", + "목 전방 굴곡 스트레칭", + "목 후방 신전", + "날개뼈 모으기", + "벽 밀기", + "가슴 펴기 (Chest Stretch - Doorway)", + "손깍지 끼고 가슴 열기", + "흉추 신전", + "고양이-낙타 자세" + ) } } From 3415acd47a15e6f0cd6af5bd6b563fbae156ca26 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Fri, 21 Nov 2025 15:02:48 +0900 Subject: [PATCH 20/29] =?UTF-8?q?chore:=20=EC=A0=90=EC=88=98=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/dashboard/application/ScoreService.kt | 12 ++++-------- .../domain/session/application/SessionService.kt | 12 ++++++------ .../kusitms_bugi/domain/session/domain/Session.kt | 2 +- .../domain/session/infrastructure/jpa/Session.kt | 9 ++++----- .../domain/session/presentation/SessionApi.kt | 4 ++-- .../domain/session/presentation/SessionController.kt | 4 ++-- .../presentation/dto/request/SaveMetricsRequest.kt | 8 +------- .../db/migration/V6__change_score_to_int.sql | 1 + 8 files changed, 21 insertions(+), 31 deletions(-) create mode 100644 src/main/resources/db/migration/V6__change_score_to_int.sql 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 index f49f9df..f43c3f4 100644 --- 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 @@ -5,11 +5,7 @@ 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.abs -import kotlin.math.exp -import kotlin.math.max -import kotlin.math.min -import kotlin.math.round +import kotlin.math.* @Service class ScoreService { @@ -60,7 +56,7 @@ class ScoreService { private fun extractStreaks( activeMetrics: List, session: Session, - condition: (Double) -> Boolean + condition: (Int) -> Boolean ): List { if (activeMetrics.isEmpty()) return emptyList() @@ -121,7 +117,7 @@ class ScoreService { activeMetrics: List, sessionLength: Double ): Double { - val goodStreaks = extractStreaks(activeMetrics, session) { score -> score <= 2.0 } + val goodStreaks = extractStreaks(activeMetrics, session) { score -> score >= 4 } if (goodStreaks.isEmpty()) return 0.0 val baseGood = goodStreaks.sumOf { streak -> @@ -145,7 +141,7 @@ class ScoreService { activeMetrics: List, sessionLength: Double ): Double { - val badStreaks = extractStreaks(activeMetrics, session) { score -> score >= 5.0 } + val badStreaks = extractStreaks(activeMetrics, session) { score -> score <= 3 } if (badStreaks.isEmpty()) return 0.0 val baseBad = badStreaks.sumOf { streak -> 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 index b3d30d5..92d72f2 100644 --- 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 @@ -6,7 +6,7 @@ import com.github.kusitms_bugi.domain.session.domain.SessionStatus 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.SaveMetricsRequest +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 @@ -105,17 +105,17 @@ class SessionService( @Transactional @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") - fun saveMetrics(session: Session, request: SaveMetricsRequest) { + fun saveMetrics(session: Session, request: Collection) { session.lastStatus() .takeIf { it == SessionStatus.STARTED || it == SessionStatus.RESUMED } ?: throw ApiException(SessionExceptionCode.SESSION_NOT_ACTIVE) - request.metrics.forEach { metricData -> + request.forEach { SessionMetric( session = session, - score = metricData.score, - timestamp = metricData.timestamp - ).let { session.metrics.add(it) } + 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 index 28685e7..593f4d3 100644 --- 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 @@ -26,6 +26,6 @@ interface SessionStatusHistoryField : BaseField { interface SessionMetricField : BaseField { var session: SESSION - var score: Double + var score: Int var timestamp: LocalDateTime } \ No newline at end of file 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 index eadda67..4d78b6c 100644 --- 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 @@ -95,7 +95,7 @@ class Session( val levelDurations = (1..6).associateWith { 0L }.toMutableMap() activeMetrics.zipWithNext().forEach { (current, next) -> - val level = current.score.toInt().coerceIn(1, 6) + val level = current.score.coerceIn(1, 6) val duration = Duration.between(current.timestamp, next.timestamp) val pausedDuration = calculatePausedDuration(current.timestamp, next.timestamp) val activeDuration = duration.minus(pausedDuration).toMillis() @@ -110,7 +110,7 @@ class Session( fun calculateGoodSeconds(): Long { return metrics.zipWithNext() - .filter { (_, current) -> current.score <= 1.2 } + .filter { (_, current) -> current.score >= 4 } .sumOf { (prev, current) -> val duration = ChronoUnit.MILLIS.between(prev.timestamp, current.timestamp) val pausedDuration = calculatePausedDuration(prev.timestamp, current.timestamp).toMillis() @@ -145,9 +145,8 @@ class SessionMetric( override var session: Session, @Column(nullable = false) - override var score: Double, + override var score: Int, - @CreatedDate @Column(nullable = false) - override var timestamp: LocalDateTime = LocalDateTime.now() + override var timestamp: LocalDateTime ) : BaseEntity(), SessionMetricField \ No newline at end of file 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 index 47409f2..ce57769 100644 --- 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 @@ -1,7 +1,7 @@ package com.github.kusitms_bugi.domain.session.presentation import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session -import com.github.kusitms_bugi.domain.session.presentation.dto.request.SaveMetricsRequest +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 @@ -52,6 +52,6 @@ interface SessionApi { @PostMapping("/{session}/metrics") fun saveMetrics( @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session, - @Validated @RequestBody request: SaveMetricsRequest + @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 index 6eff288..1101902 100644 --- 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 @@ -2,7 +2,7 @@ package com.github.kusitms_bugi.domain.session.presentation import com.github.kusitms_bugi.domain.session.application.SessionService import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session -import com.github.kusitms_bugi.domain.session.presentation.dto.request.SaveMetricsRequest +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 @@ -37,7 +37,7 @@ class SessionController( return ApiResponse.success() } - override fun saveMetrics(session: Session, request: SaveMetricsRequest): ApiResponse { + override fun saveMetrics(session: Session, request: List): ApiResponse { sessionService.saveMetrics(session, 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 index 34c752a..877efdf 100644 --- 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 @@ -3,16 +3,10 @@ package com.github.kusitms_bugi.domain.session.presentation.dto.request import jakarta.validation.constraints.Max import jakarta.validation.constraints.Min import java.time.LocalDateTime -import java.util.* - -data class SaveMetricsRequest( - val sessionId: UUID, - val metrics: List -) data class MetricData( @field:Min(value = 1, message = "점수는 최소 1이어야 합니다.") @field:Max(value = 6, message = "점수는 최대 6이어야 합니다.") - val score: Double, + val score: Int, val timestamp: LocalDateTime ) 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; From 2f5b7c12ea855f3b35f2793752f83e81c5ba3a6e Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Fri, 21 Nov 2025 16:16:31 +0900 Subject: [PATCH 21/29] =?UTF-8?q?fix:=20=EC=95=88=EB=90=98=EB=8A=94=20API?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AttendanceService.kt | 47 ++++++++----------- .../dashboard/application/HighlightService.kt | 29 ++++++------ .../application/PosturePatternService.kt | 2 +- .../dashboard/presentation/DashboardApi.kt | 7 +-- .../presentation/DashboardController.kt | 7 +-- ...riodRequest.kt => GetAttendanceRequest.kt} | 7 +-- .../dto/request/GetHighlightRequest.kt | 17 +++++++ .../dto/response/AttendanceResponse.kt | 6 +-- 8 files changed, 66 insertions(+), 56 deletions(-) rename src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/{GetPeriodRequest.kt => GetAttendanceRequest.kt} (83%) create mode 100644 src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetHighlightRequest.kt 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 index 2cacc91..29dbdeb 100644 --- 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 @@ -1,6 +1,6 @@ package com.github.kusitms_bugi.domain.dashboard.application -import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetPeriodRequest +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.Session import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionJpaRepository @@ -17,35 +17,16 @@ class AttendanceService( private val sessionJpaRepository: SessionJpaRepository ) { - fun getAttendance(user: User, request: GetPeriodRequest): AttendanceResponse { - val (startDate, endDate) = when (request.period) { - GetPeriodRequest.Period.MONTHLY -> { - val yearMonth = YearMonth.of(request.year, request.month ?: 1) - yearMonth.atDay(1).atStartOfDay() to yearMonth.atEndOfMonth().atTime(23, 59, 59) - } - GetPeriodRequest.Period.YEARLY -> { - return AttendanceResponse( - attendances = emptyMap(), - title = "", - content1 = "", - content2 = "", - subContent = "" - ) - } - GetPeriodRequest.Period.WEEKLY -> { - return AttendanceResponse( - attendances = emptyMap(), - title = "", - content1 = "", - content2 = "", - subContent = "" - ) - } - } + fun getAttendance(user: User, request: GetAttendanceRequest): AttendanceResponse { + val yearMonth = YearMonth.of(request.year, request.month) - val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween(user, startDate, endDate) + val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween( + user = user, + startDate = yearMonth.atDay(1).atStartOfDay(), + endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59) + ) - val attendances = sessions + val sessionsByDate = sessions .groupBy { it.createdAt.toLocalDate() } .mapValues { (_, sessionsOnDate) -> sessionsOnDate.sumOf { session -> @@ -53,6 +34,16 @@ class AttendanceService( } } + val today = LocalDate.now() + val attendances = (1..yearMonth.lengthOfMonth()).associate { day -> + val date = yearMonth.atDay(day) + val value = when { + date.isAfter(today) -> null + else -> sessionsByDate[date] ?: 0 + } + date to value + } + val recentSevenDaysStart = LocalDate.now().minusDays(6) val previousSevenDaysStart = LocalDate.now().minusDays(13) val previousSevenDaysEnd = LocalDate.now().minusDays(7) 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 index df8d3a2..7687dd8 100644 --- 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 @@ -1,6 +1,6 @@ package com.github.kusitms_bugi.domain.dashboard.application -import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetPeriodRequest +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.Session import com.github.kusitms_bugi.domain.session.infrastructure.jpa.SessionJpaRepository @@ -18,11 +18,10 @@ class HighlightService( private val sessionJpaRepository: SessionJpaRepository ) { - fun getHighlight(user: User, request: GetPeriodRequest): HighlightResponse { + fun getHighlight(user: User, request: GetHighlightRequest): HighlightResponse { return when (request.period) { - GetPeriodRequest.Period.WEEKLY -> calculateWeeklyHighlight(user) - GetPeriodRequest.Period.MONTHLY -> calculateMonthlyHighlight(user, request.year, request.month ?: 1) - GetPeriodRequest.Period.YEARLY -> HighlightResponse(current = 0, previous = 0) + GetHighlightRequest.Period.WEEKLY -> calculateWeeklyHighlight(user) + GetHighlightRequest.Period.MONTHLY -> calculateMonthlyHighlight(user) } } @@ -45,13 +44,14 @@ class HighlightService( ) return HighlightResponse( - current = calculateAverageBadPostureMinutes(thisWeekSessions), - previous = calculateAverageBadPostureMinutes(lastWeekSessions) + current = calculateSession(thisWeekSessions), + previous = calculateSession(lastWeekSessions) ) } - private fun calculateMonthlyHighlight(user: User, year: Int, month: Int): HighlightResponse { - val thisMonth = YearMonth.of(year, month) + private fun calculateMonthlyHighlight(user: User): HighlightResponse { + val today = LocalDate.now() + val thisMonth = YearMonth.from(today) val lastMonth = thisMonth.minusMonths(1) val thisMonthSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( @@ -67,20 +67,19 @@ class HighlightService( ) return HighlightResponse( - current = calculateAverageBadPostureMinutes(thisMonthSessions), - previous = calculateAverageBadPostureMinutes(lastMonthSessions) + current = calculateSession(thisMonthSessions), + previous = calculateSession(lastMonthSessions) ) } - private fun calculateAverageBadPostureMinutes(sessions: List): Int { + private fun calculateSession(sessions: List): Int { if (sessions.isEmpty()) return 0 - val totalBadPostureMillis = sessions.sumOf { session -> + val millis = sessions.sumOf { session -> val levelDurations = session.getLevelDurations() (levelDurations[4] ?: 0L) + (levelDurations[5] ?: 0L) + (levelDurations[6] ?: 0L) } - val averageMillis = totalBadPostureMillis / sessions.size - return (averageMillis / 1000 / 60).toInt() + return ((millis / sessions.size) / 1000).toInt() } } 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 index 02f8bf1..915fd22 100644 --- 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 @@ -106,7 +106,7 @@ class PosturePatternService( if (recoveryTimes.isEmpty()) return 0 val averageRecoveryMillis = recoveryTimes.average() - return (averageRecoveryMillis / 1000 / 60).toInt() + return (averageRecoveryMillis / 1000).toInt() } private fun getRandomStretching(user: User): String { 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 index 22d1eb1..acda6a1 100644 --- 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 @@ -1,6 +1,7 @@ package com.github.kusitms_bugi.domain.dashboard.presentation -import com.github.kusitms_bugi.domain.dashboard.presentation.dto.request.GetPeriodRequest +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 @@ -26,7 +27,7 @@ interface DashboardApi { @GetMapping("/attendance") fun getAttendance( @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails, - @Validated request: GetPeriodRequest + @Validated request: GetAttendanceRequest ): ApiResponse @Operation(summary = "레벨 도달 현황 조회") @@ -45,7 +46,7 @@ interface DashboardApi { @GetMapping("/highlight") fun getHighlight( @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails, - @Validated request: GetPeriodRequest + @Validated request: GetHighlightRequest ): ApiResponse @Operation(summary = "자세 패턴 분석 조회") 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 index 6c6c7bb..1087071 100644 --- 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 @@ -1,7 +1,8 @@ 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.GetPeriodRequest +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 @@ -27,7 +28,7 @@ class DashboardController( override fun getAttendance( @AuthenticationPrincipal userDetails: CustomUserDetails, - @Validated request: GetPeriodRequest + @Validated request: GetAttendanceRequest ): ApiResponse { return ApiResponse.success(attendanceService.getAttendance(userDetails.user, request)) } @@ -46,7 +47,7 @@ class DashboardController( override fun getHighlight( @AuthenticationPrincipal userDetails: CustomUserDetails, - @Validated request: GetPeriodRequest + @Validated request: GetHighlightRequest ): ApiResponse { return ApiResponse.success(highlightService.getHighlight(userDetails.user, request)) } diff --git a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetAttendanceRequest.kt similarity index 83% rename from src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt rename to src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetAttendanceRequest.kt index f444365..52e7bae 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetPeriodRequest.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/domain/dashboard/presentation/dto/request/GetAttendanceRequest.kt @@ -6,7 +6,7 @@ import org.springdoc.core.annotations.ParameterObject @ParameterObject @Schema(description = "출석 현황 조회 요청") -data class GetPeriodRequest( +data class GetAttendanceRequest( @field:Schema(description = "조회 기간") @field:NotNull(message = "조회 기간은 필수입니다") var period: Period, @@ -16,9 +16,10 @@ data class GetPeriodRequest( var year: Int, @field:Schema(description = "조회 월") - var month: Int? = null + @field:NotNull(message = "월은 필수입니다") + var month: Int ) { enum class Period { - WEEKLY, MONTHLY, YEARLY + 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 index 9f0ee97..6503186 100644 --- 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 @@ -6,10 +6,10 @@ import java.time.LocalDate @Schema(description = "출석 현황 응답") data class AttendanceResponse( @field:Schema( - description = "출석 데이터", - example = "{\"2025-01-01\": 3, \"2025-01-02\": 5, \"2025-01-03\": 2}" + description = "출석 데이터 (오늘 이전 날짜는 데이터 없으면 0, 오늘 이후 날짜는 null)", + example = "{\"2025-01-01\": 3, \"2025-01-02\": 5, \"2025-01-03\": 2, \"2025-01-25\": null}" ) - val attendances: Map, + val attendances: Map, @field:Schema( description = "피드백 제목", From dbfa8e2ab956e37baa53e92e82ab9ade68e8146d Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Fri, 21 Nov 2025 23:52:20 +0900 Subject: [PATCH 22/29] =?UTF-8?q?refactor:=20Native=20Query=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AttendanceService.kt | 155 +++---- .../application/AverageScoreService.kt | 38 +- .../dashboard/application/HighlightService.kt | 44 +- .../dashboard/application/LevelService.kt | 22 +- .../application/PostureGraphService.kt | 18 +- .../application/PosturePatternService.kt | 110 ++--- .../session/infrastructure/jpa/Session.kt | 3 + .../jpa/SessionJpaRepository.kt | 416 +++++++++++++++++- 8 files changed, 558 insertions(+), 248 deletions(-) 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 index 29dbdeb..c8fcbf1 100644 --- 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 @@ -2,7 +2,6 @@ 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.Session 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 @@ -11,6 +10,15 @@ 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( @@ -19,51 +27,49 @@ class AttendanceService( fun getAttendance(user: User, request: GetAttendanceRequest): AttendanceResponse { val yearMonth = YearMonth.of(request.year, request.month) + val today = LocalDate.now() - val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user = user, - startDate = yearMonth.atDay(1).atStartOfDay(), - endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59) + val earliestDate = minOf( + yearMonth.atDay(1), + today.minusDays(13) ) - val sessionsByDate = sessions - .groupBy { it.createdAt.toLocalDate() } - .mapValues { (_, sessionsOnDate) -> - sessionsOnDate.sumOf { session -> - (session.calculateTotalActiveSeconds() / 60.0).toInt() - } - } + 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 today = LocalDate.now() val attendances = (1..yearMonth.lengthOfMonth()).associate { day -> val date = yearMonth.atDay(day) val value = when { date.isAfter(today) -> null - else -> sessionsByDate[date] ?: 0 + else -> allStats[date]?.activeMinutes ?: 0 } date to value } - val recentSevenDaysStart = LocalDate.now().minusDays(6) - val previousSevenDaysStart = LocalDate.now().minusDays(13) - val previousSevenDaysEnd = LocalDate.now().minusDays(7) - - val recentSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, - recentSevenDaysStart.atStartOfDay(), - LocalDate.now().atTime(23, 59, 59) - ) + val recentStats = (0..6).mapNotNull { offset -> + allStats[today.minusDays(offset.toLong())] + } - val previousSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, - previousSevenDaysStart.atStartOfDay(), - previousSevenDaysEnd.atTime(23, 59, 59) - ) + val previousStats = (7..13).mapNotNull { offset -> + allStats[today.minusDays(offset.toLong())] + } - val title = calculateTitle(recentSessions, previousSessions) - val content1 = calculateContent1(user, recentSessions) - val content2 = calculateContent2(user, recentSessions) - val subContent = calculateSubContent(recentSessions) + 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, @@ -74,11 +80,11 @@ class AttendanceService( ) } - private fun calculateTitle(recentSessions: List, previousSessions: List): String { - val recentBadTime = calculateTotalBadPostureTime(recentSessions) - val previousBadTime = calculateTotalBadPostureTime(previousSessions) - val recentGoodRatio = calculateGoodPostureRatio(recentSessions) - val previousGoodRatio = calculateGoodPostureRatio(previousSessions) + 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 badTimeIncreased = recentBadTime > previousBadTime val goodRatioDecreased = recentGoodRatio < previousGoodRatio @@ -90,26 +96,22 @@ class AttendanceService( } } - private fun calculateContent1(user: User, recentSessions: List): String { - val recentSevenDaysStart = LocalDate.now().minusDays(6) - val allUsersSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, - recentSevenDaysStart.atStartOfDay(), - LocalDate.now().atTime(23, 59, 59) - ) - - val myAverageScore = recentSessions - .mapNotNull { it.score?.toDouble() } + 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() ?: 50.0 + ?.average() ?: 3.5 - val allScores = allUsersSessions - .mapNotNull { it.score?.toDouble() } - .takeIf { it.isNotEmpty() } ?: listOf(50.0) + val allLevels = allStats + .filter { it.avgLevel > 0 } + .map { it.avgLevel } + .takeIf { it.isNotEmpty() } ?: listOf(3.5) - val higherCount = allScores.count { score -> score < myAverageScore } - val percentile = if (allScores.isNotEmpty()) { - round((higherCount.toDouble() / allScores.size) * 100).toInt() + val worseCount = allLevels.count { level -> level > myAverageLevel } + val percentile = if (allLevels.isNotEmpty()) { + round((worseCount.toDouble() / allLevels.size) * 100).toInt() } else { 50 } @@ -117,16 +119,9 @@ class AttendanceService( return "전체 사용자 중, 당신의 자세는 상위 ${percentile}%예요" } - private fun calculateContent2(user: User, recentSessions: List): String { - val recentSevenDaysStart = LocalDate.now().minusDays(6) - val allUsersSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, - recentSevenDaysStart.atStartOfDay(), - LocalDate.now().atTime(23, 59, 59) - ) - - val myGoodRatio = calculateGoodPostureRatio(recentSessions) - val allUsersGoodRatio = calculateGoodPostureRatio(allUsersSessions) + private fun calculateContent2(recentStats: List, allStats: List): String { + val myGoodRatio = calculateGoodRatio(recentStats) + val allUsersGoodRatio = calculateGoodRatio(allStats) val difference = round(myGoodRatio - allUsersGoodRatio).toInt() @@ -137,14 +132,10 @@ class AttendanceService( } } - private fun calculateSubContent(sessions: List): String { - val averageLevel = sessions - .flatMap { session -> - val levelDurations = session.getLevelDurations() - (1..6).flatMap { level -> - List((levelDurations[level] ?: 0L).toInt()) { level } - } - } + private fun calculateSubContent(stats: List): String { + val averageLevel = stats + .filter { it.avgLevel > 0 } + .map { it.avgLevel } .takeIf { it.isNotEmpty() } ?.average() ?: 3.0 @@ -157,25 +148,11 @@ class AttendanceService( } } - private fun calculateTotalBadPostureTime(sessions: List): Long { - return sessions.sumOf { session -> - val levelDurations = session.getLevelDurations() - (levelDurations[4] ?: 0L) + (levelDurations[5] ?: 0L) + (levelDurations[6] ?: 0L) - } - } - - private fun calculateGoodPostureRatio(sessions: List): Double { - val totalDuration = sessions.sumOf { session -> - session.getLevelDurations().values.sum() - }.toDouble() - + private fun calculateGoodRatio(stats: List): Double { + val totalDuration = stats.sumOf { it.totalDurationMillis }.toDouble() if (totalDuration == 0.0) return 0.0 - val goodDuration = sessions.sumOf { session -> - val levelDurations = session.getLevelDurations() - (levelDurations[1] ?: 0L) + (levelDurations[2] ?: 0L) + (levelDurations[3] ?: 0L) - } - + 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 index c2057d8..afe1e03 100644 --- 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 @@ -1,13 +1,11 @@ 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.Session 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 -import kotlin.math.round @Service @Transactional(readOnly = true) @@ -18,44 +16,18 @@ class AverageScoreService( fun getAverageScore(user: User): AverageScoreResponse { val thirtyDaysAgo = LocalDateTime.now().minusDays(ANALYSIS_PERIOD_DAYS) - val averageScore = sessionJpaRepository.findByUserAndCreatedAtAfter(user, thirtyDaysAgo) - .takeIf { it.isNotEmpty() } - ?.mapNotNull { session -> calculateCompositeScore(session) } - ?.takeIf { it.isNotEmpty() } - ?.average() - ?.let { round(it).toInt().coerceIn(MIN_SCORE, MAX_SCORE) } - ?: DEFAULT_SCORE + val averageScore = sessionJpaRepository.getAverageCompositeScore( + user.id, + thirtyDaysAgo + ) ?: DEFAULT_SCORE - return AverageScoreResponse(score = averageScore) + return AverageScoreResponse(score = averageScore.coerceIn(MIN_SCORE, MAX_SCORE)) } - private fun calculateCompositeScore(session: Session): Double? { - val finalScore = session.score?.toDouble() ?: return null - val levelDurations = session.getLevelDurations() - val totalDuration = levelDurations.values.sum().toDouble() - - val pz = when { - totalDuration == 0.0 -> DEFAULT_PZ - else -> { - val goodLevelDuration = levelDurations[LEVEL_1].orZero() + levelDurations[LEVEL_2].orZero() - (goodLevelDuration / totalDuration) * 100.0 - } - } - - return (finalScore * FINAL_SCORE_WEIGHT) + (pz * PZ_WEIGHT) - } - - private fun Long?.orZero() = this ?: 0L - companion object { private const val ANALYSIS_PERIOD_DAYS = 30L - private const val FINAL_SCORE_WEIGHT = 0.65 - private const val PZ_WEIGHT = 0.35 private const val DEFAULT_SCORE = 50 - private const val DEFAULT_PZ = 50.0 private const val MIN_SCORE = 1 private const val MAX_SCORE = 100 - private const val LEVEL_1 = 1 - private const val LEVEL_2 = 2 } } 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 index 7687dd8..05edde9 100644 --- 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 @@ -2,7 +2,6 @@ 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.Session 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 @@ -31,21 +30,21 @@ class HighlightService( val lastWeekStart = thisWeekStart.minusWeeks(1) val lastWeekEnd = thisWeekStart.minusDays(1) - val thisWeekSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, + val current = sessionJpaRepository.getHighlightStats( + user.id, thisWeekStart.atStartOfDay(), today.atTime(23, 59, 59) - ) + ) ?: 0 - val lastWeekSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, + val previous = sessionJpaRepository.getHighlightStats( + user.id, lastWeekStart.atStartOfDay(), lastWeekEnd.atTime(23, 59, 59) - ) + ) ?: 0 return HighlightResponse( - current = calculateSession(thisWeekSessions), - previous = calculateSession(lastWeekSessions) + current = current, + previous = previous ) } @@ -54,32 +53,21 @@ class HighlightService( val thisMonth = YearMonth.from(today) val lastMonth = thisMonth.minusMonths(1) - val thisMonthSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, + val current = sessionJpaRepository.getHighlightStats( + user.id, thisMonth.atDay(1).atStartOfDay(), thisMonth.atEndOfMonth().atTime(23, 59, 59) - ) + ) ?: 0 - val lastMonthSessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, + val previous = sessionJpaRepository.getHighlightStats( + user.id, lastMonth.atDay(1).atStartOfDay(), lastMonth.atEndOfMonth().atTime(23, 59, 59) - ) + ) ?: 0 return HighlightResponse( - current = calculateSession(thisMonthSessions), - previous = calculateSession(lastMonthSessions) + current = current, + previous = previous ) } - - private fun calculateSession(sessions: List): Int { - if (sessions.isEmpty()) return 0 - - val millis = sessions.sumOf { session -> - val levelDurations = session.getLevelDurations() - (levelDurations[4] ?: 0L) + (levelDurations[5] ?: 0L) + (levelDurations[6] ?: 0L) - } - - return ((millis / sessions.size) / 1000).toInt() - } } 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 index e56cab9..308af86 100644 --- 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 @@ -1,11 +1,11 @@ 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.Session 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 @@ -15,8 +15,7 @@ class LevelService( ) { fun getLevel(user: User): LevelResponse { - val sessions = sessionJpaRepository.findByUser(user) - val totalDistance = calculateTotalDistance(sessions) + val totalDistance = calculateTotalDistanceFromDb(user.id) val currentLevel = calculateLevel(totalDistance) val levelStartDistance = calculateLevelStartDistance(currentLevel) val currentProgress = (totalDistance - levelStartDistance).toInt() @@ -29,15 +28,16 @@ class LevelService( ) } - private fun calculateTotalDistance(sessions: List): Double { - return sessions.sumOf { session -> - val levelDurations = session.getLevelDurations() - - LEVEL_SPEEDS.entries.sumOf { (level, speed) -> - val durationMillis = levelDurations[level] ?: 0L - val durationHours = durationMillis / 1000.0 / 3600.0 - durationHours * speed + 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 } } 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 index f0ee354..ece2806 100644 --- 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 @@ -6,7 +6,6 @@ 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 kotlin.math.round @Service @Transactional(readOnly = true) @@ -22,23 +21,16 @@ class PostureGraphService( today.minusDays(offset.toLong()) }.reversed() - val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, + val scoresByDate = sessionJpaRepository.getAverageScoresByDate( + user.id, startDate.atStartOfDay(), today.atTime(23, 59, 59) - ) - - val sessionsByDay = sessions.groupBy { session -> - session.createdAt.toLocalDate() + ).associate { result -> + LocalDate.parse(result[0] as String) to (result[1] as Number).toInt() } val points = days.associateWith { day -> - sessionsByDay[day] - ?.mapNotNull { it.score } - ?.takeIf { it.isNotEmpty() } - ?.average() - ?.let { round(it).toInt() } - ?: 0 + 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 index 915fd22..747272d 100644 --- 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 @@ -1,7 +1,6 @@ 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.Session 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 @@ -10,6 +9,15 @@ 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( @@ -18,15 +26,25 @@ class PosturePatternService( fun getPosturePattern(user: User): PosturePatternResponse { val sevenDaysAgo = LocalDate.now().minusDays(6) - val sessions = sessionJpaRepository.findByUserAndCreatedAtBetween( - user, + + 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 = calculateWorstTime(sessions) - val worstDay = calculateWorstDay(sessions) - val recovery = calculateRecovery(sessions) + val worstTime = calculateWorstTimeFromStats(stats) + val worstDay = calculateWorstDayFromStats(stats) + val recovery = calculateRecoveryFromStats(stats) return PosturePatternResponse( worstTime = worstTime, @@ -36,76 +54,34 @@ class PosturePatternService( ) } - private fun calculateWorstTime(sessions: List): LocalTime { - val badPostureDurationByHour = mutableMapOf() - - sessions.forEach { session -> - val metrics = session.getActiveMetrics() - - metrics.zipWithNext().forEach { (current, next) -> - if (current.score >= 4.0) { - val hour = current.timestamp.hour - val durationMillis = java.time.Duration.between(current.timestamp, next.timestamp).toMillis() - val pausedDuration = session.calculatePausedDuration(current.timestamp, next.timestamp).toMillis() - val activeDuration = durationMillis - pausedDuration + private fun calculateWorstTimeFromStats(stats: List): LocalTime { + val goodPostureDurationByHour = stats + .groupBy { it.hourOfDay } + .mapValues { (_, hourStats) -> hourStats.sumOf { it.goodPostureDurationMillis } } - if (activeDuration > 0) { - badPostureDurationByHour[hour] = badPostureDurationByHour.getOrDefault(hour, 0L) + activeDuration - } - } - } - } - - val worstHour = badPostureDurationByHour.maxByOrNull { it.value }?.key ?: 12 + val worstHour = goodPostureDurationByHour.maxByOrNull { it.value }?.key ?: 12 return LocalTime.of(worstHour, 0, 0) } - private fun calculateWorstDay(sessions: List): DayOfWeek { - val badPostureDurationByDay = mutableMapOf() - - sessions.forEach { session -> - val dayOfWeek = session.createdAt.dayOfWeek - val levelDurations = session.getLevelDurations() - val badPostureDuration = (levelDurations[4] ?: 0L) + - (levelDurations[5] ?: 0L) + - (levelDurations[6] ?: 0L) + private fun calculateWorstDayFromStats(stats: List): DayOfWeek { + val goodPostureDurationByDay = stats + .groupBy { it.dayOfWeek } + .mapValues { (_, dayStats) -> dayStats.sumOf { it.goodPostureDurationMillis } } - badPostureDurationByDay[dayOfWeek] = badPostureDurationByDay.getOrDefault(dayOfWeek, 0L) + badPostureDuration + val worstDayNum = goodPostureDurationByDay.maxByOrNull { it.value }?.key ?: 1 + return when (worstDayNum) { + 0 -> DayOfWeek.SUNDAY + else -> DayOfWeek.of(worstDayNum) } - - return badPostureDurationByDay.maxByOrNull { it.value }?.key ?: DayOfWeek.MONDAY } - private fun calculateRecovery(sessions: List): Int { - val recoveryTimes = mutableListOf() - - sessions.forEach { session -> - val metrics = session.getActiveMetrics() - var badPostureStartTime: java.time.LocalDateTime? = null - - metrics.forEach { metric -> - when { - metric.score >= 4.0 && badPostureStartTime == null -> { - badPostureStartTime = metric.timestamp - } - metric.score < 4.0 && badPostureStartTime != null -> { - val startTime = badPostureStartTime!! - val durationMillis = java.time.Duration.between(startTime, metric.timestamp).toMillis() - val pausedDuration = session.calculatePausedDuration(startTime, metric.timestamp).toMillis() - val activeDuration = durationMillis - pausedDuration - - if (activeDuration > 0) { - recoveryTimes.add(activeDuration) - } - badPostureStartTime = null - } - } - } - } + private fun calculateRecoveryFromStats(stats: List): Int { + val totalRecoveryTime = stats.sumOf { it.avgRecoveryTimeMillis * it.recoveryCount } + val totalRecoveryCount = stats.sumOf { it.recoveryCount } - if (recoveryTimes.isEmpty()) return 0 + if (totalRecoveryCount == 0) return 0 - val averageRecoveryMillis = recoveryTimes.average() + val averageRecoveryMillis = totalRecoveryTime.toDouble() / totalRecoveryCount return (averageRecoveryMillis / 1000).toInt() } 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 index 4d78b6c..fbe54dc 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -22,10 +23,12 @@ class Session( @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) 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 index 46c8cc9..77da261 100644 --- 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 @@ -1,6 +1,5 @@ package com.github.kusitms_bugi.domain.session.infrastructure.jpa -import com.github.kusitms_bugi.domain.user.infrastructure.jpa.User import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -12,12 +11,415 @@ interface SessionJpaRepository : JpaRepository { @Query("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.id = :id") override fun findById(@Param("id") id: UUID): Optional - @Query("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.user = :user") - fun findByUser(@Param("user") user: User): List + @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("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.user = :user AND s.createdAt > :createdAt") - fun findByUserAndCreatedAtAfter(@Param("user") user: User, @Param("createdAt") createdAt: 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("SELECT DISTINCT s FROM Session s LEFT JOIN FETCH s.statusHistory LEFT JOIN FETCH s.metrics WHERE s.user = :user AND s.createdAt BETWEEN :startDate AND :endDate") - fun findByUserAndCreatedAtBetween(@Param("user") user: User, @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, + 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(AVG(bad_duration_millis / 1000.0)), 0) as avg_bad_seconds_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> } From 13c09d9f477bf81a01decf32a26e32c130370479 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Sat, 22 Nov 2025 00:46:31 +0900 Subject: [PATCH 23/29] =?UTF-8?q?refactor:=20Native=20Query=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/session/infrastructure/jpa/SessionJpaRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 77da261..bb7eda4 100644 --- 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 @@ -155,7 +155,7 @@ interface SessionJpaRepository : JpaRepository { GROUP BY ar.session_id ) SELECT - COALESCE(ROUND(AVG(bad_duration_millis / 1000.0)), 0) as avg_bad_seconds_per_session + 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 """, From 4fd32ad6d7616b75544c4b4b2f16729678e06ae1 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Sat, 22 Nov 2025 01:28:35 +0900 Subject: [PATCH 24/29] =?UTF-8?q?fix:=20=EB=A9=98=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/dashboard/application/AttendanceService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index c8fcbf1..cd021c7 100644 --- 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 @@ -86,8 +86,8 @@ class AttendanceService( val recentGoodRatio = calculateGoodRatio(recentStats) val previousGoodRatio = calculateGoodRatio(previousStats) - val badTimeIncreased = recentBadTime > previousBadTime - val goodRatioDecreased = recentGoodRatio < previousGoodRatio + val badTimeIncreased = recentBadTime < previousBadTime + val goodRatioDecreased = recentGoodRatio > previousGoodRatio return when { !badTimeIncreased && !goodRatioDecreased -> "잘하고 있어요!" From 56ece5e551073d3e62a757ad3a8aa15e18316a54 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Sat, 22 Nov 2025 01:33:55 +0900 Subject: [PATCH 25/29] fix: --- .../dashboard/application/ScoreService.kt | 49 +++++++++++++++-- .../session/infrastructure/jpa/Session.kt | 54 ++++++++++++++++++- 2 files changed, 96 insertions(+), 7 deletions(-) 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 index f43c3f4..d14ad17 100644 --- 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 @@ -60,6 +60,9 @@ class ScoreService { ): List { if (activeMetrics.isEmpty()) return emptyList() + // Pause 구간들을 한 번만 미리 계산하여 재사용 (성능 최적화) + val pauseRanges = getPauseRangesFromSession(session) + val streaks = mutableListOf() var streakStartTime: LocalDateTime? = null @@ -70,7 +73,7 @@ class ScoreService { } } else { streakStartTime?.let { start -> - val activeDuration = calculateActiveDuration(session, start, metric.timestamp) + val activeDuration = calculateActiveDuration(start, metric.timestamp, pauseRanges) if (activeDuration > 0) { streaks.add(activeDuration) } @@ -81,7 +84,7 @@ class ScoreService { streakStartTime?.let { start -> val lastMetric = activeMetrics.last() - val activeDuration = calculateActiveDuration(session, start, lastMetric.timestamp) + val activeDuration = calculateActiveDuration(start, lastMetric.timestamp, pauseRanges) if (activeDuration > 0) { streaks.add(activeDuration) } @@ -90,10 +93,46 @@ class ScoreService { return streaks } - private fun calculateActiveDuration(session: Session, start: LocalDateTime, end: LocalDateTime): Double { + 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) - val pausedDuration = session.calculatePausedDuration(start, end) - return duration.minus(pausedDuration).toMillis() / 1000.0 / 60.0 + 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 { 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 index fbe54dc..22ecfbc 100644 --- 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 @@ -97,10 +97,15 @@ class Session( 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) - val pausedDuration = calculatePausedDuration(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) { @@ -111,12 +116,57 @@ class Session( 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 { + // Pause 구간들을 한 번만 미리 계산하여 재사용 + val pauseRanges = getPauseRanges() + return metrics.zipWithNext() .filter { (_, current) -> current.score >= 4 } .sumOf { (prev, current) -> val duration = ChronoUnit.MILLIS.between(prev.timestamp, current.timestamp) - val pausedDuration = calculatePausedDuration(prev.timestamp, current.timestamp).toMillis() + val pausedDuration = calculatePausedDurationFromRanges(prev.timestamp, current.timestamp, pauseRanges).toMillis() duration - pausedDuration } / 1_000 } From a957436e3856d93f601e2d91ccd0247f8f00933c Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Sat, 22 Nov 2025 01:45:21 +0900 Subject: [PATCH 26/29] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C~?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/application/SessionService.kt | 58 ++++++++++++++----- .../infrastructure/SessionRepositoryImpl.kt | 12 ++++ .../jpa/SessionJpaRepository.kt | 8 ++- .../domain/session/presentation/SessionApi.kt | 27 +++++---- .../session/presentation/SessionController.kt | 23 ++++---- .../global/exception/ApiExceptionCode.kt | 12 ++-- 6 files changed, 99 insertions(+), 41 deletions(-) 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 index 92d72f2..4afc513 100644 --- 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 @@ -1,8 +1,8 @@ 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.SessionRepository 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 @@ -12,19 +12,26 @@ import com.github.kusitms_bugi.domain.session.presentation.dto.response.GetSessi 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 org.springframework.security.access.prepost.PreAuthorize +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: SessionRepository, + private val sessionRepository: SessionRepositoryImpl, private val scoreService: ScoreService ) { @Transactional(readOnly = true) - @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") - fun getSession(session: Session): GetSessionReportResponse { + 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) @@ -51,8 +58,14 @@ class SessionService( } @Transactional - @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") - fun pauseSession(session: Session) { + 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) @@ -68,8 +81,14 @@ class SessionService( } @Transactional - @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") - fun resumeSession(session: Session) { + 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) @@ -85,8 +104,14 @@ class SessionService( } @Transactional - @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") - fun stopSession(session: Session) { + 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) @@ -104,8 +129,15 @@ class SessionService( } @Transactional - @PreAuthorize("@sessionPermissionEvaluator.canAccessSession(principal, #session)") - fun saveMetrics(session: Session, request: Collection) { + 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) 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 index 3577f5c..e22d8ec 100644 --- 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 @@ -17,4 +17,16 @@ class SessionRepositoryImpl( 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/SessionJpaRepository.kt b/src/main/kotlin/com/github/kusitms_bugi/domain/session/infrastructure/jpa/SessionJpaRepository.kt index bb7eda4..1fbf2f1 100644 --- 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 @@ -9,7 +9,13 @@ 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") - override fun findById(@Param("id") id: UUID): Optional + 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 = """ 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 index ce57769..07642ca 100644 --- 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 @@ -1,6 +1,5 @@ package com.github.kusitms_bugi.domain.session.presentation -import com.github.kusitms_bugi.domain.session.infrastructure.jpa.Session 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 @@ -13,15 +12,17 @@ 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("/{session}/report") + @GetMapping("/{sessionId}/report") fun getSessionReport( - @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable sessionId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails ): ApiResponse @Operation(summary = "세션 생성") @@ -31,27 +32,31 @@ interface SessionApi { ): ApiResponse @Operation(summary = "세션 일시정지") - @PatchMapping("/{session}/pause") + @PatchMapping("/{sessionId}/pause") fun pauseSession( - @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable sessionId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails ): ApiResponse @Operation(summary = "세션 재개") - @PatchMapping("/{session}/resume") + @PatchMapping("/{sessionId}/resume") fun resumeSession( - @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable sessionId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails ): ApiResponse @Operation(summary = "세션 중단") - @PatchMapping("/{session}/stop") + @PatchMapping("/{sessionId}/stop") fun stopSession( - @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session + @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable sessionId: UUID, + @Parameter(hidden = true) @AuthenticationPrincipal userDetails: CustomUserDetails ): ApiResponse @Operation(summary = "세션 메트릭 저장") - @PostMapping("/{session}/metrics") + @PostMapping("/{sessionId}/metrics") fun saveMetrics( - @Parameter(description = "세션 ID", schema = Schema(type = "string", format = "uuid")) @PathVariable session: Session, + @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 index 1101902..5a052bd 100644 --- 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 @@ -1,44 +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.infrastructure.jpa.Session 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(session: Session): ApiResponse { - return ApiResponse.success(sessionService.getSession(session)) + 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(session: Session): ApiResponse { - sessionService.pauseSession(session) + override fun pauseSession(sessionId: UUID, @AuthenticationPrincipal userDetails: CustomUserDetails): ApiResponse { + sessionService.pauseSession(sessionId, userDetails) return ApiResponse.success() } - override fun resumeSession(session: Session): ApiResponse { - sessionService.resumeSession(session) + override fun resumeSession(sessionId: UUID, @AuthenticationPrincipal userDetails: CustomUserDetails): ApiResponse { + sessionService.resumeSession(sessionId, userDetails) return ApiResponse.success() } - override fun stopSession(session: Session): ApiResponse { - sessionService.stopSession(session) + override fun stopSession(sessionId: UUID, @AuthenticationPrincipal userDetails: CustomUserDetails): ApiResponse { + sessionService.stopSession(sessionId, userDetails) return ApiResponse.success() } - override fun saveMetrics(session: Session, request: List): ApiResponse { - sessionService.saveMetrics(session, request) + 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/global/exception/ApiExceptionCode.kt b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt index ca0b06e..76f6373 100644 --- a/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt +++ b/src/main/kotlin/com/github/kusitms_bugi/global/exception/ApiExceptionCode.kt @@ -47,9 +47,11 @@ enum class SessionExceptionCode( override val code: String, override val message: String ) : ApiExceptionCode { - SESSION_ALREADY_STOPPED("SESSION-101", "이미 종료된 세션입니다."), - SESSION_NOT_STOPPED("SESSION-102", "종료되지 않은 세션입니다."), - SESSION_NOT_RESUMED("SESSION-103", "일시정지 상태가 아닌 세션입니다."), - SESSION_NOT_PAUSED("SESSION-104", "재개 상태가 아닌 세션입니다."), - SESSION_NOT_ACTIVE("SESSION-105", "활성 상태가 아닌 세션입니다."), + 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 From 654b6ec9cfeef3e874cc14f5d2d43a9ac62935d1 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Sat, 22 Nov 2025 01:57:54 +0900 Subject: [PATCH 27/29] =?UTF-8?q?fix:=20=EC=8A=A4=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=B9=AD=20=EB=AC=B8=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/dashboard/application/PosturePatternService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 747272d..0bbab66 100644 --- 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 @@ -100,7 +100,7 @@ class PosturePatternService( "목 후방 신전", "날개뼈 모으기", "벽 밀기", - "가슴 펴기 (Chest Stretch - Doorway)", + "가슴 펴기", "손깍지 끼고 가슴 열기", "흉추 신전", "고양이-낙타 자세" From 70061880bc5014af9d44fd188ecda6b88acb8a7c Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Tue, 25 Nov 2025 21:41:24 +0900 Subject: [PATCH 28/29] =?UTF-8?q?fix:=20=EB=B0=94=EB=A5=B8=20=EC=9E=90?= =?UTF-8?q?=EC=84=B8=20=EC=8B=9C=EA=B0=84=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/application/AttendanceService.kt | 8 ++++---- .../domain/dashboard/application/ScoreService.kt | 8 ++++---- .../domain/session/infrastructure/jpa/Session.kt | 15 ++++++++++----- 3 files changed, 18 insertions(+), 13 deletions(-) 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 index cd021c7..18149e1 100644 --- 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 @@ -86,12 +86,12 @@ class AttendanceService( val recentGoodRatio = calculateGoodRatio(recentStats) val previousGoodRatio = calculateGoodRatio(previousStats) - val badTimeIncreased = recentBadTime < previousBadTime - val goodRatioDecreased = recentGoodRatio > previousGoodRatio + val badTimeDecreased = recentBadTime < previousBadTime + val goodRatioIncreased = recentGoodRatio > previousGoodRatio return when { - !badTimeIncreased && !goodRatioDecreased -> "잘하고 있어요!" - badTimeIncreased && goodRatioDecreased -> "주의가 필요해요!" + badTimeDecreased && goodRatioIncreased -> "잘하고 있어요!" + !badTimeDecreased && !goodRatioIncreased -> "주의가 필요해요!" else -> "조금만 더 힘내봐요!" } } 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 index d14ad17..32ec228 100644 --- 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 @@ -40,10 +40,10 @@ class ScoreService { if (activeMetrics.size < 2) return 0 var flips = 0 - var previousSide = if (activeMetrics.first().score < 3.0) "good" else "bad" + var previousSide = if (activeMetrics.first().score <= 3) "good" else "bad" for (i in 1 until activeMetrics.size) { - val currentSide = if (activeMetrics[i].score < 3.0) "good" else "bad" + val currentSide = if (activeMetrics[i].score <= 3) "good" else "bad" if (currentSide != previousSide) { flips++ previousSide = currentSide @@ -156,7 +156,7 @@ class ScoreService { activeMetrics: List, sessionLength: Double ): Double { - val goodStreaks = extractStreaks(activeMetrics, session) { score -> score >= 4 } + val goodStreaks = extractStreaks(activeMetrics, session) { score -> score <= 3 } if (goodStreaks.isEmpty()) return 0.0 val baseGood = goodStreaks.sumOf { streak -> @@ -180,7 +180,7 @@ class ScoreService { activeMetrics: List, sessionLength: Double ): Double { - val badStreaks = extractStreaks(activeMetrics, session) { score -> score <= 3 } + val badStreaks = extractStreaks(activeMetrics, session) { score -> score >= 4 } if (badStreaks.isEmpty()) return 0.0 val baseBad = badStreaks.sumOf { streak -> 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 index 22ecfbc..441af99 100644 --- 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 @@ -159,16 +159,21 @@ class Session( } fun calculateGoodSeconds(): Long { - // Pause 구간들을 한 번만 미리 계산하여 재사용 val pauseRanges = getPauseRanges() + val activeMetrics = getActiveMetrics() - return metrics.zipWithNext() - .filter { (_, current) -> current.score >= 4 } + 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() - duration - pausedDuration - } / 1_000 + 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 } } From 08a26c1fd1ab8c20672b6b5a17e3b582a0fcebef Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Thu, 27 Nov 2025 22:19:27 +0900 Subject: [PATCH 29/29] =?UTF-8?q?fix:=20=EC=9D=B4=EB=8F=99=20=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/dashboard/application/LevelService.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index 308af86..3a69eb2 100644 --- 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 @@ -71,12 +71,12 @@ class LevelService( companion object { private val LEVEL_SPEEDS = mapOf( - 1 to 0.1, - 2 to 50.0, - 3 to 200.0, - 4 to 500.0, - 5 to 1500.0, - 6 to 3000.0 + 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