This commit is contained in:
2026-02-02 12:29:00 +09:00
parent f8f5cef6e1
commit 495ef7d86c
175 changed files with 45128 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.java]
max_line_length = 180

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

74
.gitignore vendored Normal file
View File

@@ -0,0 +1,74 @@
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/
### QueryDSL ###
/src/main/generated/
**/generated/
### Logs ###
*.log
logs/
*.log.*
### OS ###
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
### Environment ###
.env
.env.local
.env.*.local
application-local.yml
application-secret.yml
### Docker (local testing) ###
.dockerignore
docker-compose.override.yml
### Temporary ###
*.tmp
*.temp
*.swp
*.swo
*~
!/CLAUDE.md

15
Dockerfile-dev Normal file
View File

@@ -0,0 +1,15 @@
# Stage 1: Build stage (gradle build는 Jenkins에서 이미 수행)
FROM eclipse-temurin:21-jre-jammy
# 작업 디렉토리 설정
WORKDIR /app
# JAR 파일 복사 (Jenkins에서 빌드된 ROOT.jar)
COPY build/libs/ROOT.jar app.jar
# 포트 노출
EXPOSE 8080
# 애플리케이션 실행
# dev 프로파일로 실행
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=dev", "app.jar"]

145
Jenkinsfile-dev Normal file
View File

@@ -0,0 +1,145 @@
pipeline {
agent any
tools {
jdk 'jdk21'
}
environment {
BRANCH = 'develop'
GIT_REPO = 'https://kamco.gitea.gs.dabeeo.com/dabeeo/kamco-dabeeo-training-api.git'
DEPLOY_SERVER = '192.168.2.109'
DEPLOY_USER = 'space'
DEPLOY_PATH = '/home/space//kamco-training-api'
SSH_CREDENTIALS_ID = 'jenkins-251215'
}
stages {
stage('Checkout') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "${env.BRANCH}"]],
userRemoteConfigs: [[
url: "${env.GIT_REPO}",
credentialsId: 'jenkins-dev-token'
]]
])
}
}
stage('Get Commit Hash') {
steps {
script {
env.COMMIT_HASH = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim()
echo "Current commit hash: ${env.COMMIT_HASH}"
}
}
}
stage('Build') {
steps {
sh "./gradlew clean build -x test"
}
}
stage('Transfer Project Files') {
steps {
script {
echo "Transferring project files to ${env.DEPLOY_SERVER}..."
sshagent(credentials: ["${env.SSH_CREDENTIALS_ID}"]) {
sh """
# Create deployment directory structure on remote server
ssh -o StrictHostKeyChecking=no ${env.DEPLOY_USER}@${env.DEPLOY_SERVER} \
"mkdir -p ${env.DEPLOY_PATH}/build/libs"
# Transfer build artifacts with directory structure
scp -o StrictHostKeyChecking=no \
build/libs/ROOT.jar \
${env.DEPLOY_USER}@${env.DEPLOY_SERVER}:${env.DEPLOY_PATH}/build/libs/
# Transfer Docker files
scp -o StrictHostKeyChecking=no \
Dockerfile-dev \
${env.DEPLOY_USER}@${env.DEPLOY_SERVER}:${env.DEPLOY_PATH}/
scp -o StrictHostKeyChecking=no \
docker-compose-dev.yml \
${env.DEPLOY_USER}@${env.DEPLOY_SERVER}:${env.DEPLOY_PATH}/
echo "✅ Project files transferred successfully"
"""
}
}
}
}
stage('Docker Compose Deploy') {
steps {
script {
echo "Deploying with Docker Compose on ${env.DEPLOY_SERVER}..."
sshagent(credentials: ["${env.SSH_CREDENTIALS_ID}"]) {
sh """
ssh -o StrictHostKeyChecking=no ${env.DEPLOY_USER}@${env.DEPLOY_SERVER} << 'EOF'
cd ${env.DEPLOY_PATH}
# Set IMAGE_TAG environment variable
export IMAGE_TAG=${env.COMMIT_HASH}
# Stop and remove existing containers
echo "Stopping existing containers..."
docker compose -f docker-compose-dev.yml down || true
# Build new Docker image
echo "Building Docker image with tag: \${IMAGE_TAG}..."
docker compose -f docker-compose-dev.yml build
# Tag as latest
docker tag kamco-cd-training-api:\${IMAGE_TAG} kamco-cd-training-api:latest
# Start containers
echo "Starting containers..."
docker compose -f docker-compose-dev.yml up -d
# Wait for application to be ready
echo "Waiting for application to be ready..."
for i in {1..30}; do
if curl -f http://localhost:7200/monitor/health > /dev/null 2>&1; then
echo "✅ Application is healthy!"
docker compose -f docker-compose-dev.yml ps
exit 0
fi
echo "⏳ Waiting for application... (\$i/30)"
sleep 2
done
echo "⚠️ Warning: Health check timeout"
docker compose -f docker-compose-dev.yml ps
docker compose -f docker-compose-dev.yml logs --tail=50
exit 1
EOF
"""
}
}
}
}
stage('Cleanup Old Images') {
steps {
script {
echo "Cleaning up old Docker images..."
sshagent(credentials: ["${env.SSH_CREDENTIALS_ID}"]) {
sh """
ssh -o StrictHostKeyChecking=no ${env.DEPLOY_USER}@${env.DEPLOY_SERVER} \
"docker images kamco-cd-training-api --format '{{.ID}} {{.Tag}}' | \
grep -v latest | tail -n +6 | awk '{print \\\$1}' | xargs -r docker rmi || true"
echo "✅ Cleanup completed"
"""
}
}
}
}
}
}

114
build.gradle Normal file
View File

@@ -0,0 +1,114 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.7'
id 'io.spring.dependency-management' version '1.1.7'
id 'com.diffplug.spotless' version '6.25.0'
}
group = 'com.kamco.cd'
version = '0.0.1-SNAPSHOT'
description = 'kamco-dabeeo-training-api'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
maven { url "https://repo.osgeo.org/repository/release/" }
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.boot:spring-boot-starter-validation'
//geometry
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'org.locationtech.jts.io:jts-io-common:1.20.0'
implementation 'org.locationtech.jts:jts-core:1.19.0'
implementation 'org.hibernate:hibernate-spatial:6.2.7.Final'
implementation 'org.geotools:gt-main:30.0'
implementation("org.geotools:gt-geotiff:30.0") {
exclude group: "javax.media", module: "jai_core"
}
implementation 'org.geotools:gt-epsg-hsql:30.0'
// QueryDSL JPA
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
// Q클래스 생성용 annotationProcessor
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
// actuator
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// Redis : training server는 레이디스 없이 진행합니다.
// implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// SpringDoc OpenAPI (Swagger)
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
// Apache Commons Compress for archive handling
implementation 'org.apache.commons:commons-compress:1.26.0'
// crypto
implementation 'org.mindrot:jbcrypt:0.4'
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
// JWT (jjwt 0.12.x) : training server는 토큰 DB 인증 방식으로 진행
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' // JSON (Jackson)
// Hibernate Types for JSONB support
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.7.0'
implementation 'org.reflections:reflections:0.10.2'
}
configurations.configureEach {
exclude group: 'javax.media', module: 'jai_core'
}
tasks.named('test') {
useJUnitPlatform()
}
bootJar {
archiveFileName = 'ROOT.jar'
}
// Spotless configuration for code formatting (2-space indent)
spotless {
java {
target 'src/**/*.java'
googleJavaFormat('1.19.2') // Default Google Style = 2 spaces (NO .aosp()!)
trimTrailingWhitespace()
endWithNewline()
}
}
// Run spotlessCheck before build
tasks.named('build') {
dependsOn 'spotlessCheck'
}

29
docker-compose-dev.yml Normal file
View File

@@ -0,0 +1,29 @@
services:
kamco-changedetection-api:
build:
context: .
dockerfile: Dockerfile-dev
image: kamco-cd-training-api:${IMAGE_TAG:-latest}
container_name: kamco-cd-training-api
ports:
- "7200:8080"
environment:
- SPRING_PROFILES_ACTIVE=dev
- TZ=Asia/Seoul
volumes:
- /mnt/nfs_share/images:/app/original-images
- /mnt/nfs_share/model_output:/app/model-outputs
- /mnt/nfs_share/train_dataset:/app/train-dataset
networks:
- kamco-cds
restart: unless-stopped
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/monitor/health" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 40s
networks:
kamco-cds:
external: true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Normal file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -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

4
http/CommonCode.http Normal file
View File

@@ -0,0 +1,4 @@
### GET getByCodeId
GET http://localhost:8080/api/code/1
Content-Type: application/json
###

View File

@@ -0,0 +1,598 @@
<?xml version="1.0" encoding="UTF-8"?>
<code_scheme name="GoogleStyle">
<option name="OTHER_INDENT_OPTIONS">
<value>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="false" />
<option name="SMART_TABS" value="false" />
<option name="LABEL_INDENT_SIZE" value="0" />
<option name="LABEL_INDENT_ABSOLUTE" value="false" />
<option name="USE_RELATIVE_INDENTS" value="false" />
</value>
</option>
<option name="INSERT_INNER_CLASS_IMPORTS" value="true" />
<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="999" />
<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="999" />
<option name="PACKAGES_TO_USE_IMPORT_ON_DEMAND">
<value />
</option>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
</value>
</option>
<option name="RIGHT_MARGIN" value="180" />
<option name="JD_ALIGN_PARAM_COMMENTS" value="false" />
<option name="JD_ALIGN_EXCEPTION_COMMENTS" value="false" />
<option name="JD_P_AT_EMPTY_LINES" value="false" />
<option name="JD_KEEP_EMPTY_PARAMETER" value="false" />
<option name="JD_KEEP_EMPTY_EXCEPTION" value="false" />
<option name="JD_KEEP_EMPTY_RETURN" value="false" />
<option name="KEEP_CONTROL_STATEMENT_IN_ONE_LINE" value="false" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="BLANK_LINES_AFTER_CLASS_HEADER" value="0" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="CALL_PARAMETERS_WRAP" value="1" />
<option name="METHOD_PARAMETERS_WRAP" value="1" />
<option name="EXTENDS_LIST_WRAP" value="1" />
<option name="THROWS_KEYWORD_WRAP" value="1" />
<option name="METHOD_CALL_CHAIN_WRAP" value="1" />
<option name="BINARY_OPERATION_WRAP" value="1" />
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
<option name="TERNARY_OPERATION_WRAP" value="1" />
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
<option name="FOR_STATEMENT_WRAP" value="1" />
<option name="ARRAY_INITIALIZER_WRAP" value="1" />
<option name="WRAP_COMMENTS" value="true" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
<option name="SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE" value="true" />
<AndroidXmlCodeStyleSettings>
<option name="USE_CUSTOM_SETTINGS" value="true" />
<option name="LAYOUT_SETTINGS">
<value>
<option name="INSERT_BLANK_LINE_BEFORE_TAG" value="false" />
</value>
</option>
</AndroidXmlCodeStyleSettings>
<JSCodeStyleSettings>
<option name="INDENT_CHAINED_CALLS" value="false" />
</JSCodeStyleSettings>
<Python>
<option name="USE_CONTINUATION_INDENT_FOR_ARGUMENTS" value="true" />
</Python>
<TypeScriptCodeStyleSettings>
<option name="INDENT_CHAINED_CALLS" value="false" />
</TypeScriptCodeStyleSettings>
<XML>
<option name="XML_ALIGN_ATTRIBUTES" value="false" />
<option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" />
</XML>
<codeStyleSettings language="CSS">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="ECMA Script Level 4">
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="CALL_PARAMETERS_WRAP" value="1" />
<option name="METHOD_PARAMETERS_WRAP" value="1" />
<option name="EXTENDS_LIST_WRAP" value="1" />
<option name="BINARY_OPERATION_WRAP" value="1" />
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
<option name="TERNARY_OPERATION_WRAP" value="1" />
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
<option name="FOR_STATEMENT_WRAP" value="1" />
<option name="ARRAY_INITIALIZER_WRAP" value="1" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
<option name="PARENT_SETTINGS_INSTALLED" value="true" />
</codeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JAVA">
<option name="KEEP_CONTROL_STATEMENT_IN_ONE_LINE" value="false" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="BLANK_LINES_AFTER_CLASS_HEADER" value="1" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_RESOURCES" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="CALL_PARAMETERS_WRAP" value="1" />
<option name="METHOD_PARAMETERS_WRAP" value="1" />
<option name="EXTENDS_LIST_WRAP" value="1" />
<option name="THROWS_KEYWORD_WRAP" value="1" />
<option name="METHOD_CALL_CHAIN_WRAP" value="1" />
<option name="BINARY_OPERATION_WRAP" value="1" />
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
<option name="TERNARY_OPERATION_WRAP" value="1" />
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
<option name="FOR_STATEMENT_WRAP" value="1" />
<option name="ARRAY_INITIALIZER_WRAP" value="1" />
<option name="WRAP_COMMENTS" value="true" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
<option name="PARENT_SETTINGS_INSTALLED" value="true" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="RIGHT_MARGIN" value="80" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="CALL_PARAMETERS_WRAP" value="1" />
<option name="METHOD_PARAMETERS_WRAP" value="1" />
<option name="BINARY_OPERATION_WRAP" value="1" />
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
<option name="TERNARY_OPERATION_WRAP" value="1" />
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
<option name="FOR_STATEMENT_WRAP" value="1" />
<option name="ARRAY_INITIALIZER_WRAP" value="1" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
<option name="PARENT_SETTINGS_INSTALLED" value="true" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="PROTO">
<option name="RIGHT_MARGIN" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="protobuf">
<option name="RIGHT_MARGIN" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Python">
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="RIGHT_MARGIN" value="80" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="PARENT_SETTINGS_INSTALLED" value="true" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="SASS">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="SCSS">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:.*Style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_width</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_height</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_weight</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_margin</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginTop</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginBottom</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginStart</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginEnd</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginLeft</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginRight</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:padding</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingTop</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingBottom</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingStart</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingEnd</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingLeft</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingRight</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res-auto</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/tools</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<Objective-C>
<option name="INDENT_NAMESPACE_MEMBERS" value="0" />
<option name="INDENT_C_STRUCT_MEMBERS" value="2" />
<option name="INDENT_CLASS_MEMBERS" value="2" />
<option name="INDENT_VISIBILITY_KEYWORDS" value="1" />
<option name="INDENT_INSIDE_CODE_BLOCK" value="2" />
<option name="KEEP_STRUCTURES_IN_ONE_LINE" value="true" />
<option name="FUNCTION_PARAMETERS_WRAP" value="5" />
<option name="FUNCTION_CALL_ARGUMENTS_WRAP" value="5" />
<option name="TEMPLATE_CALL_ARGUMENTS_WRAP" value="5" />
<option name="TEMPLATE_CALL_ARGUMENTS_ALIGN_MULTILINE" value="true" />
<option name="ALIGN_INIT_LIST_IN_COLUMNS" value="false" />
<option name="SPACE_BEFORE_SUPERCLASS_COLON" value="false" />
</Objective-C>
<Objective-C-extensions>
<option name="GENERATE_INSTANCE_VARIABLES_FOR_PROPERTIES" value="ASK" />
<option name="RELEASE_STYLE" value="IVAR" />
<option name="TYPE_QUALIFIERS_PLACEMENT" value="BEFORE" />
<file>
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Import" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Macro" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Typedef" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Enum" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Constant" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Global" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Struct" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="FunctionPredecl" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Function" />
</file>
<class>
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Property" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Synthesize" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="InitMethod" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="StaticMethod" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="InstanceMethod" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="DeallocMethod" />
</class>
<extensions>
<pair source="cc" header="h" />
<pair source="c" header="h" />
</extensions>
</Objective-C-extensions>
<codeStyleSettings language="ObjectiveC">
<option name="RIGHT_MARGIN" value="80" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="1" />
<option name="BLANK_LINES_BEFORE_IMPORTS" value="0" />
<option name="BLANK_LINES_AFTER_IMPORTS" value="0" />
<option name="BLANK_LINES_AROUND_CLASS" value="0" />
<option name="BLANK_LINES_AROUND_METHOD" value="0" />
<option name="BLANK_LINES_AROUND_METHOD_IN_INTERFACE" value="0" />
<option name="ALIGN_MULTILINE_BINARY_OPERATION" value="false" />
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
<option name="FOR_STATEMENT_WRAP" value="1" />
<option name="ASSIGNMENT_WRAP" value="1" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme>

23
scripts/health-check.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Health Check Script with Authentication
BASE_URL="http://localhost:8080"
echo "================================"
echo "1. Health Check (No Auth)"
echo "================================"
curl -s "${BASE_URL}/monitor/health" | jq .
echo ""
echo "================================"
echo "2. Health Check with Details (With Auth)"
echo "================================"
curl -s -u admin:admin123 "${BASE_URL}/monitor/health" | jq .
echo ""
echo "================================"
echo "3. DiskSpace Only (With Auth)"
echo "================================"
curl -s -u admin:admin123 "${BASE_URL}/monitor/health" | jq '.components.diskSpace'
echo ""

6
settings.gradle Normal file
View File

@@ -0,0 +1,6 @@
pluginManagement {
plugins {
id 'org.jetbrains.kotlin.jvm' version '2.2.20'
}
}
rootProject.name = 'kamco-dabeeo-training-api'

View File

@@ -0,0 +1,14 @@
package com.kamco.cd.training;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class KamcoTrainingApplication {
public static void main(String[] args) {
SpringApplication.run(KamcoTrainingApplication.class, args);
}
}

View File

@@ -0,0 +1,22 @@
package com.kamco.cd.training.auth;
import java.security.SecureRandom;
import java.util.Base64;
public class BCryptSaltGenerator {
public static String generateSaltWithEmployeeNo(String employeeNo) {
// bcrypt salt는 16바이트(128비트) 필요
byte[] randomBytes = new byte[16];
new SecureRandom().nextBytes(randomBytes);
String base64 = Base64.getEncoder().encodeToString(randomBytes);
// 사번을 포함 (22자 제한 → 잘라내기)
String mixedSalt = (employeeNo + base64).substring(0, 22);
// bcrypt 포맷에 맞게 구성
return "$2a$10$" + mixedSalt;
}
}

View File

@@ -0,0 +1,62 @@
package com.kamco.cd.training.auth;
import com.kamco.cd.training.common.enums.StatusType;
import com.kamco.cd.training.common.enums.error.AuthErrorCode;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.postgres.entity.MemberEntity;
import com.kamco.cd.training.postgres.repository.members.MembersRepository;
import lombok.RequiredArgsConstructor;
import org.mindrot.jbcrypt.BCrypt;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final MembersRepository membersRepository;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String rawPassword = authentication.getCredentials().toString();
// 유저 조회
MemberEntity member =
membersRepository
.findByEmployeeNo(username)
.orElseThrow(() -> new CustomApiException(AuthErrorCode.LOGIN_ID_NOT_FOUND));
// 미사용 상태
if (member.getStatus().equals(StatusType.INACTIVE.getId())) {
throw new CustomApiException(AuthErrorCode.LOGIN_ID_NOT_FOUND);
}
// jBCrypt + 커스텀 salt 로 저장된 패스워드 비교
if (!BCrypt.checkpw(rawPassword, member.getPassword())) {
// 실패 카운트 저장
int cnt = member.getLoginFailCount() + 1;
member.setLoginFailCount(cnt);
membersRepository.save(member);
throw new CustomApiException(AuthErrorCode.LOGIN_PASSWORD_MISMATCH);
}
// 로그인 실패 체크
if (member.getLoginFailCount() >= 5) {
throw new CustomApiException(AuthErrorCode.LOGIN_PASSWORD_EXCEEDED);
}
// 인증 성공 → UserDetails 생성
CustomUserDetails userDetails = new CustomUserDetails(member);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}

View File

@@ -0,0 +1,56 @@
package com.kamco.cd.training.auth;
import com.kamco.cd.training.postgres.entity.MemberEntity;
import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class CustomUserDetails implements UserDetails {
private final MemberEntity member;
public CustomUserDetails(MemberEntity member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + member.getUserRole()));
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return String.valueOf(member.getUuid());
}
@Override
public boolean isAccountNonExpired() {
return true; // 추후 상태 필드에 따라 수정 가능
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return "ACTIVE".equals(member.getStatus());
}
public MemberEntity getMember() {
return member;
}
}

View File

@@ -0,0 +1,70 @@
package com.kamco.cd.training.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
private static final String[] EXCLUDE_PATHS = {
"/api/auth/signin", "/api/auth/refresh", "/api/auth/logout", "/api/members/*/password"
};
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.isValidToken(token)) {
String username = jwtTokenProvider.getSubject(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
// JWT 필터를 타지 않게 할 URL 패턴들
for (String pattern : EXCLUDE_PATHS) {
if (PATH_MATCHER.match(pattern, path)) {
return true;
}
}
return false;
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}

View File

@@ -0,0 +1,72 @@
package com.kamco.cd.training.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-validity-in-ms}")
private long accessTokenValidityInMs;
@Value("${jwt.refresh-token-validity-in-ms}")
private long refreshTokenValidityInMs;
private SecretKey key;
@PostConstruct
public void init() {
// HS256용 SecretKey
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String createAccessToken(String subject) {
return createToken(subject, accessTokenValidityInMs);
}
public String createRefreshToken(String subject) {
return createToken(subject, refreshTokenValidityInMs);
}
private String createToken(String subject, long validityInMs) {
Date now = new Date();
Date expiry = new Date(now.getTime() + validityInMs);
return Jwts.builder().subject(subject).issuedAt(now).expiration(expiry).signWith(key).compact();
}
public String getSubject(String token) {
var claims = parseClaims(token).getPayload();
return claims.getSubject();
}
public boolean isValidToken(String token) {
try {
Jws<Claims> claims = parseClaims(token);
return !claims.getPayload().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
private Jws<Claims> parseClaims(String token) {
return Jwts.parser()
.verifyWith(key) // SecretKey 타입
.build()
.parseSignedClaims(token);
}
public long getRefreshTokenValidityInMs() {
return refreshTokenValidityInMs;
}
}

View File

@@ -0,0 +1,299 @@
package com.kamco.cd.training.code;
import com.kamco.cd.training.code.dto.CommonCodeDto;
import com.kamco.cd.training.code.service.CommonCodeService;
import com.kamco.cd.training.common.utils.CommonCodeUtil;
import com.kamco.cd.training.common.utils.enums.CodeDto;
import com.kamco.cd.training.config.api.ApiResponseDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "공통코드 관리", description = "공통코드 관리 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/code")
public class CommonCodeApiController {
private final CommonCodeService commonCodeService;
private final CommonCodeUtil commonCodeUtil;
@Operation(summary = "목록 조회", description = "모든 공통코드 조회")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CommonCodeDto.Basic.class))),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping
public ApiResponseDto<List<CommonCodeDto.Basic>> getFindAll() {
return ApiResponseDto.createOK(commonCodeService.getFindAll());
}
@Operation(summary = "단건 조회", description = "단건 조회")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CommonCodeDto.Basic.class))),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/{id}")
public ApiResponseDto<CommonCodeDto.Basic> getOneById(
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "단건 조회", required = true)
@PathVariable
Long id) {
return ApiResponseDto.ok(commonCodeService.getOneById(id));
}
@Operation(summary = "저장", description = "공통코드를 저장 합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "201",
description = "공통코드 저장 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping
public ApiResponseDto<ApiResponseDto.ResponseObj> save(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "공통코드 생성 요청 정보",
required = true,
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CommonCodeDto.AddReq.class)))
@RequestBody
@Valid
CommonCodeDto.AddReq req) {
return ApiResponseDto.okObject(commonCodeService.save(req));
}
@Operation(summary = "수정", description = "공통코드를 수정 합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "204",
description = "공통코드 수정 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PutMapping("/{id}")
public ApiResponseDto<ApiResponseDto.ResponseObj> update(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "공통코드 수정 요청 정보",
required = true,
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CommonCodeDto.ModifyReq.class)))
@PathVariable
Long id,
@RequestBody @Valid CommonCodeDto.ModifyReq req) {
return ApiResponseDto.okObject(commonCodeService.update(id, req));
}
@Operation(summary = "삭제", description = "공통코드를 삭제 합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "204",
description = "공통코드 삭제 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@DeleteMapping("/{id}")
public ApiResponseDto<ApiResponseDto.ResponseObj> remove(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "공통코드 삭제 요청 정보",
required = true)
@PathVariable
Long id) {
return ApiResponseDto.okObject(commonCodeService.removeCode(id));
}
@Operation(summary = "순서 변경", description = "공통코드 순서를 변경 합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "204",
description = "공통코드 순서 변경 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PutMapping("/order")
public ApiResponseDto<ApiResponseDto.ResponseObj> updateOrder(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "공통코드 순서변경 요청 정보",
required = true,
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CommonCodeDto.OrderReq.class)))
@RequestBody
@Valid
CommonCodeDto.OrderReq req) {
return ApiResponseDto.okObject(commonCodeService.updateOrder(req));
}
@Operation(summary = "code 기반 조회", description = "code 기반 조회")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "code 기반 조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/used")
public ApiResponseDto<List<CommonCodeDto.Basic>> getByCode(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "공통코드 순서변경 요청 정보",
required = true)
@RequestParam
String code) {
return ApiResponseDto.ok(commonCodeService.findByCode(code));
}
@Operation(summary = "변화탐지 분류 코드 목록", description = "변화탐지 분류 코드 목록(공통코드 기반)")
@GetMapping("/clazz")
public ApiResponseDto<List<CommonCodeDto.Clazzes>> getClasses() {
// List<Clazzes> list =
// Arrays.stream(DetectionClassification.values())
// .sorted(Comparator.comparingInt(DetectionClassification::getOrder))
// .map(Clazzes::new)
// .toList();
// 변화탐지 clazz API : enum -> 공통코드로 변경
List<CommonCodeDto.Clazzes> list =
commonCodeUtil.getChildCodesByParentCode("0000").stream()
.map(
child ->
new CommonCodeDto.Clazzes(
child.getCode(), child.getName(), child.getOrder(), child.getProps2()))
.toList();
return ApiResponseDto.ok(list);
}
@Operation(summary = "공통코드 중복여부 체크", description = "공통코드 중복여부 체크")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CommonCodeDto.Basic.class))),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/check-duplicate")
public ApiResponseDto<ApiResponseDto.ResponseObj> getCodeCheckDuplicate(
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "단건 조회", required = true)
@RequestParam(required = false)
Long parentId,
@RequestParam String code) {
return ApiResponseDto.okObject(commonCodeService.getCodeCheckDuplicate(parentId, code));
}
@Operation(summary = "코드 조회", description = "코드 리스트 조회")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "코드 조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CodeDto.class))),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/type/codes")
public ApiResponseDto<Map<String, List<CodeDto>>> getTypeCodes() {
return ApiResponseDto.ok(commonCodeService.getTypeCodes());
}
@Operation(summary = "코드 단건 조회", description = "코드 조회")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "코드 조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CodeDto.class))),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/type/{type}")
public ApiResponseDto<List<CodeDto>> getTypeCode(@PathVariable String type) {
return ApiResponseDto.ok(commonCodeService.getTypeCode(type));
}
@Operation(summary = "캐시 초기화", description = "공통코드 캐시를 초기화합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "캐시 초기화 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/cache/refresh")
public ApiResponseDto<String> refreshCommonCodeCache() {
commonCodeService.refresh();
return ApiResponseDto.ok("공통코드 캐시가 초기화 되었습니다.");
}
}

View File

@@ -0,0 +1,179 @@
package com.kamco.cd.training.code.dto;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.kamco.cd.training.common.utils.html.HtmlEscapeDeserializer;
import com.kamco.cd.training.common.utils.html.HtmlUnescapeSerializer;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.time.ZonedDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public class CommonCodeDto {
@Schema(name = "CodeAddReq", description = "공통코드 저장 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class AddReq {
@NotEmpty private String code;
@NotEmpty private String name;
private String description;
private int order;
private boolean used;
private Long parentId;
@JsonDeserialize(using = HtmlEscapeDeserializer.class)
private String props1;
@JsonDeserialize(using = HtmlEscapeDeserializer.class)
private String props2;
@JsonDeserialize(using = HtmlEscapeDeserializer.class)
private String props3;
}
@Schema(name = "CodeModifyReq", description = "공통코드 수정 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class ModifyReq {
@NotEmpty private String name;
private String description;
private boolean used;
@JsonDeserialize(using = HtmlEscapeDeserializer.class)
private String props1;
@JsonDeserialize(using = HtmlEscapeDeserializer.class)
private String props2;
@JsonDeserialize(using = HtmlEscapeDeserializer.class)
private String props3;
}
@Schema(name = "CodeOrderReq", description = "공통코드 순서 변경 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class OrderReq {
@NotNull private Long id;
@NotNull private Integer order;
}
@Schema(name = "CommonCode Basic", description = "공통코드 기본 정보")
@Getter
public static class Basic {
private Long id;
private String code;
private String description;
private String name;
private Integer order;
private Boolean used;
private Boolean deleted;
private List<CommonCodeDto.Basic> children;
@JsonFormatDttm private ZonedDateTime createdDttm;
@JsonFormatDttm private ZonedDateTime updatedDttm;
@JsonSerialize(using = HtmlUnescapeSerializer.class)
private String props1;
@JsonSerialize(using = HtmlUnescapeSerializer.class)
private String props2;
@JsonSerialize(using = HtmlUnescapeSerializer.class)
private String props3;
@JsonFormatDttm private ZonedDateTime deletedDttm;
public Basic(
Long id,
String code,
String description,
String name,
Integer order,
Boolean used,
Boolean deleted,
List<CommonCodeDto.Basic> children,
ZonedDateTime createdDttm,
ZonedDateTime updatedDttm,
String props1,
String props2,
String props3,
ZonedDateTime deletedDttm) {
this.id = id;
this.code = code;
this.description = description;
this.name = name;
this.order = order;
this.used = used;
this.deleted = deleted;
this.children = children;
this.createdDttm = createdDttm;
this.updatedDttm = updatedDttm;
this.props1 = props1;
this.props2 = props2;
this.props3 = props3;
this.deletedDttm = deletedDttm;
}
}
@Schema(name = "SearchReq", description = "검색 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SearchReq {
// 검색 조건
private String name;
// 페이징 파라미터
private int page = 0;
private int size = 20;
private String sort;
public Pageable toPageable() {
if (sort != null && !sort.isEmpty()) {
String[] sortParams = sort.split(",");
String property = sortParams[0];
Sort.Direction direction =
sortParams.length > 1 ? Sort.Direction.fromString(sortParams[1]) : Sort.Direction.ASC;
return PageRequest.of(page, size, Sort.by(direction, property));
}
return PageRequest.of(page, size);
}
}
@Getter
public static class Clazzes {
private String code;
private String name;
private Integer order;
private String color;
public Clazzes(String code, String name, Integer order, String color) {
this.code = code;
this.name = name;
this.order = order;
this.color = color;
}
}
}

View File

@@ -0,0 +1,155 @@
package com.kamco.cd.training.code.service;
import com.kamco.cd.training.code.dto.CommonCodeDto.AddReq;
import com.kamco.cd.training.code.dto.CommonCodeDto.Basic;
import com.kamco.cd.training.code.dto.CommonCodeDto.ModifyReq;
import com.kamco.cd.training.code.dto.CommonCodeDto.OrderReq;
import com.kamco.cd.training.common.utils.enums.CodeDto;
import com.kamco.cd.training.common.utils.enums.Enums;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.postgres.core.CommonCodeCoreService;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// training 서버는 Redis 사용하지 않고 Spring Boot 메모리 캐시를 사용함
// => org.springframework.cache.annotation.Cacheable
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CommonCodeService {
private final CommonCodeCoreService commonCodeCoreService;
/**
* 공통코드 목록 조회
*
* @return 모튼 코드 정보
*/
@Cacheable("trainCommonCodes")
public List<Basic> getFindAll() {
return commonCodeCoreService.findAll();
}
/**
* 공통코드 단건 조회
*
* @param id
* @return 코드 아이디로 조회한 코드 정보
*/
public Basic getOneById(Long id) {
return commonCodeCoreService.getOneById(id);
}
/**
* 공통코드 생성 요청
*
* @param req 생성요청 정보
* @return 생성된 코드 id
*/
@Transactional
@CacheEvict(value = "trainCommonCodes", allEntries = true)
public ApiResponseDto.ResponseObj save(AddReq req) {
return commonCodeCoreService.save(req);
}
/**
* 공통코드 수정 요청
*
* @param id 코드 아이디
* @param req 수정요청 정보
*/
@Transactional
@CacheEvict(value = "trainCommonCodes", allEntries = true)
public ApiResponseDto.ResponseObj update(Long id, ModifyReq req) {
return commonCodeCoreService.update(id, req);
}
/**
* 공통코드 삭제 처리
*
* @param id 코드 아이디
*/
@Transactional
@CacheEvict(value = "trainCommonCodes", allEntries = true)
public ApiResponseDto.ResponseObj removeCode(Long id) {
return commonCodeCoreService.removeCode(id);
}
/**
* 공통코드 순서 변경
*
* @param req id, order 정보를 가진 List
*/
@Transactional
@CacheEvict(value = "trainCommonCodes", allEntries = true)
public ApiResponseDto.ResponseObj updateOrder(OrderReq req) {
return commonCodeCoreService.updateOrder(req);
}
/**
* 코드기반 조회
*
* @param code 코드
* @return 코드로 조회한 공통코드 정보
*/
public List<Basic> findByCode(String code) {
return commonCodeCoreService.findByCode(code);
}
/**
* 중복 체크
*
* @param parentId
* @param code
* @return
*/
public ApiResponseDto.ResponseObj getCodeCheckDuplicate(Long parentId, String code) {
return commonCodeCoreService.getCodeCheckDuplicate(parentId, code);
}
/**
* 공통코드 이름 조회
*
* @param parentCodeCd 상위 코드
* @param childCodeCd 하위 코드
* @return 공통코드명
*/
public Optional<String> getCode(String parentCodeCd, String childCodeCd) {
return commonCodeCoreService.getCode(parentCodeCd, childCodeCd);
}
/**
* 공통코드 이름 조회
*
* @param parentCodeCd 상위 코드
* @param childCodeCd 하위 코드
* @return 공통코드명
*/
public Optional<String> getTypeCode(String parentCodeCd, String childCodeCd) {
return commonCodeCoreService.getCode(parentCodeCd, childCodeCd);
}
public List<CodeDto> getTypeCode(String type) {
return Enums.getCodes(type);
}
/**
* 공통코드 리스트 조회
*
* @return
*/
public Map<String, List<CodeDto>> getTypeCodes() {
return Enums.getAllCodes();
}
/** 메모리 캐시 초기화 */
@CacheEvict(value = "trainCommonCodes", allEntries = true)
public void refresh() {}
}

View File

@@ -0,0 +1,19 @@
package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.CodeExpose;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@CodeExpose
@Getter
@AllArgsConstructor
public enum DeployTargetType implements EnumType {
// @formatter:off
GUKU("GUKU", "국토교통부"),
PROD("PROD", "운영계");
// @formatter:on
private final String id;
private final String text;
}

View File

@@ -0,0 +1,55 @@
package com.kamco.cd.training.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum DetectionClassification {
BUILDING("building", "건물", 10),
CONTAINER("container", "컨테이너", 20),
FIELD("field", "경작지", 30),
FOREST("forest", "", 40),
GRASS("grass", "초지", 50),
GREENHOUSE("greenhouse", "비닐하우스", 60),
LAND("land", "일반토지", 70),
ORCHARD("orchard", "과수원", 80),
ROAD("road", "도로", 90),
STONE("stone", "모래/자갈", 100),
TANK("tank", "물탱크", 110),
TUMULUS("tumulus", "토분(무덤)", 120),
WASTE("waste", "폐기물", 130),
WATER("water", "", 140),
ETC("ETC", "기타", 200); // For 'etc' (miscellaneous/other)
private final String id;
private final String desc;
private final int order;
/**
* Optional: Helper method to get the enum from a String, case-insensitive, or return ETC if not
* found.
*/
public static DetectionClassification fromString(String text) {
if (text == null || text.trim().isEmpty()) {
return ETC;
}
try {
return DetectionClassification.valueOf(text.toUpperCase());
} catch (IllegalArgumentException e) {
// If the string doesn't match any enum constant name, return ETC
return ETC;
}
}
/**
* Desc 한글명 get 하기
*
* @return
*/
public static String fromStrDesc(String text) {
DetectionClassification dtf = fromString(text);
return dtf.getDesc();
}
}

View File

@@ -0,0 +1,26 @@
package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum LearnDataRegister implements EnumType {
READY("준비"),
UPLOADING("업로드중"),
UPLOAD_FAILED("업로드 실패"),
COMPLETED("완료");
private final String desc;
@Override
public String getId() {
return name();
}
@Override
public String getText() {
return desc;
}
}

View File

@@ -0,0 +1,26 @@
package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.CodeExpose;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@CodeExpose
@Getter
@AllArgsConstructor
public enum LearnDataType implements EnumType {
DELIVER("납품"),
PRODUCTION("제작");
private final String desc;
@Override
public String getId() {
return name();
}
@Override
public String getText() {
return desc;
}
}

View File

@@ -0,0 +1,27 @@
package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.CodeExpose;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@CodeExpose
@Getter
@AllArgsConstructor
public enum ModelMngStatusType implements EnumType {
READY("준비"),
IN_PROGRESS("진행중"),
COMPLETED("완료");
private String desc;
@Override
public String getId() {
return name();
}
@Override
public String getText() {
return desc;
}
}

View File

@@ -0,0 +1,20 @@
package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.CodeExpose;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@CodeExpose
@Getter
@AllArgsConstructor
public enum ProcessStepType implements EnumType {
// @formatter:off
STEP1("STEP1", "학습 중"),
STEP2("STEP2", "테스트 중"),
STEP3("STEP3", "완료");
// @formatter:on
private final String id;
private final String text;
}

View File

@@ -0,0 +1,25 @@
package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum RoleType implements EnumType {
ROLE_ADMIN("시스템 관리자"),
ROLE_LABELER("라벨러"),
ROLE_REVIEWER("검수자");
private final String desc;
@Override
public String getId() {
return name();
}
@Override
public String getText() {
return desc;
}
}

View File

@@ -0,0 +1,25 @@
package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum StatusType implements EnumType {
ACTIVE("사용"),
INACTIVE("미사용"),
PENDING("계정등록");
private final String desc;
@Override
public String getId() {
return name();
}
@Override
public String getText() {
return desc;
}
}

View File

@@ -0,0 +1,22 @@
package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.CodeExpose;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@CodeExpose
@Getter
@AllArgsConstructor
public enum TrainStatusType implements EnumType {
// @formatter:off
READY("READY", "대기"),
ING("ING", "진행중"),
COMPLETED("COMPLETED", "완료"),
STOPPED("STOPPED", "중단됨"),
ERROR("ERROR", "오류");
// @formatter:on
private final String id;
private final String text;
}

View File

@@ -0,0 +1,26 @@
package com.kamco.cd.training.common.enums.error;
import com.kamco.cd.training.common.utils.ErrorCode;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public enum AuthErrorCode implements ErrorCode {
LOGIN_ID_NOT_FOUND("LOGIN_ID_NOT_FOUND", HttpStatus.UNAUTHORIZED),
LOGIN_PASSWORD_MISMATCH("LOGIN_PASSWORD_MISMATCH", HttpStatus.UNAUTHORIZED),
LOGIN_PASSWORD_EXCEEDED("LOGIN_PASSWORD_EXCEEDED", HttpStatus.UNAUTHORIZED),
REFRESH_TOKEN_EXPIRED_OR_REVOKED("REFRESH_TOKEN_EXPIRED_OR_REVOKED", HttpStatus.UNAUTHORIZED),
REFRESH_TOKEN_MISMATCH("REFRESH_TOKEN_MISMATCH", HttpStatus.UNAUTHORIZED);
private final String code;
private final HttpStatus status;
AuthErrorCode(String code, HttpStatus status) {
this.code = code;
this.status = status;
}
}

View File

@@ -0,0 +1,10 @@
package com.kamco.cd.training.common.exception;
import org.springframework.http.HttpStatus;
public class BadRequestException extends CustomApiException {
public BadRequestException(String message) {
super("BAD_REQUEST", HttpStatus.BAD_REQUEST, message);
}
}

View File

@@ -0,0 +1,28 @@
package com.kamco.cd.training.common.exception;
import com.kamco.cd.training.common.utils.ErrorCode;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class CustomApiException extends RuntimeException {
private final String codeName; // ApiResponseCode enum name과 맞추는 용도 (예: "UNPROCESSABLE_ENTITY")
private final HttpStatus status; // 응답으로 내려줄 HttpStatus
public CustomApiException(String codeName, HttpStatus status, String message) {
super(message);
this.codeName = codeName;
this.status = status;
}
public CustomApiException(String codeName, HttpStatus status) {
this.codeName = codeName;
this.status = status;
}
public CustomApiException(ErrorCode errorCode) {
this.codeName = errorCode.getCode();
this.status = errorCode.getStatus();
}
}

View File

@@ -0,0 +1,7 @@
package com.kamco.cd.training.common.exception;
public class DuplicateFileException extends RuntimeException {
public DuplicateFileException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,10 @@
package com.kamco.cd.training.common.exception;
import org.springframework.http.HttpStatus;
public class NotFoundException extends CustomApiException {
public NotFoundException(String message) {
super("NOT_FOUND", HttpStatus.NOT_FOUND, message);
}
}

View File

@@ -0,0 +1,7 @@
package com.kamco.cd.training.common.exception;
public class ValidationException extends RuntimeException {
public ValidationException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,38 @@
package com.kamco.cd.training.common.service;
import org.springframework.data.domain.Page;
/**
* Base Core Service Interface
*
* <p>CRUD operations를 정의하는 기본 서비스 인터페이스
*
* @param <T> Entity 타입
* @param <ID> Entity의 ID 타입
* @param <S> Search Request 타입
*/
public interface BaseCoreService<T, ID, S> {
/**
* ID로 엔티티를 삭제합니다.
*
* @param id 삭제할 엔티티의 ID
*/
void remove(ID id);
/**
* ID로 단건 조회합니다.
*
* @param id 조회할 엔티티의 ID
* @return 조회된 엔티티
*/
T getOneById(ID id);
/**
* 검색 조건과 페이징으로 조회합니다.
*
* @param searchReq 검색 조건
* @return 페이징 처리된 검색 결과
*/
Page<T> search(S searchReq);
}

View File

@@ -0,0 +1,152 @@
package com.kamco.cd.training.common.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kamco.cd.training.code.dto.CommonCodeDto.Basic;
import com.kamco.cd.training.code.service.CommonCodeService;
import com.kamco.cd.training.config.api.ApiResponseDto;
import java.util.List;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 공통코드 조회 유틸리티 클래스 애플리케이션 전역에서 공통코드를 조회하기 위한 유틸리티입니다. training 서버는 Redis 사용하고 Spring 내장 메모리 캐시
* 사용합니다.
*/
//
@Slf4j
@Component
public class CommonCodeUtil {
private final CommonCodeService commonCodeService;
@Autowired private ObjectMapper objectMapper;
public CommonCodeUtil(CommonCodeService commonCodeService) {
this.commonCodeService = commonCodeService;
}
/**
* 모든 공통코드 조회
*
* @return 캐시된 모든 공통코드 목록
*/
public List<Basic> getAllCommonCodes() {
try {
return commonCodeService.getFindAll();
} catch (Exception e) {
log.error("공통코드 전체 조회 중 오류 발생", e);
return List.of();
}
}
/**
* 특정 코드로 공통코드 조회
*
* @param code 코드값
* @return 해당 코드의 공통코드 목록
*/
public List<Basic> getCommonCodesByCode(String code) {
if (code == null || code.isEmpty()) {
log.warn("유효하지 않은 코드: {}", code);
return List.of();
}
try {
return commonCodeService.findByCode(code);
} catch (Exception e) {
log.error("코드 기반 공통코드 조회 중 오류 발생: {}", code, e);
return List.of();
}
}
/**
* 특정 ID로 공통코드 단건 조회
*
* @param id 공통코드 ID
* @return 조회된 공통코드
*/
public Optional<Basic> getCommonCodeById(Long id) {
if (id == null || id <= 0) {
log.warn("유효하지 않은 ID: {}", id);
return Optional.empty();
}
try {
return Optional.of(commonCodeService.getOneById(id));
} catch (Exception e) {
log.error("ID 기반 공통코드 조회 중 오류 발생: {}", id, e);
return Optional.empty();
}
}
/**
* 상위 코드와 하위 코드로 공통코드명 조회
*
* @param parentCode 상위 코드
* @param childCode 하위 코드
* @return 공통코드명
*/
public Optional<String> getCodeName(String parentCode, String childCode) {
if (parentCode == null || parentCode.isEmpty() || childCode == null || childCode.isEmpty()) {
log.warn("유효하지 않은 코드: parentCode={}, childCode={}", parentCode, childCode);
return Optional.empty();
}
try {
return commonCodeService.getCode(parentCode, childCode);
} catch (Exception e) {
log.error("코드명 조회 중 오류 발생: parentCode={}, childCode={}", parentCode, childCode, e);
return Optional.empty();
}
}
/**
* 상위 코드를 기반으로 하위 코드 조회
*
* @param parentCode 상위 코드
* @return 해당 상위 코드의 하위 공통코드 목록
*/
public List<Basic> getChildCodesByParentCode(String parentCode) {
if (parentCode == null || parentCode.isEmpty()) {
log.warn("유효하지 않은 상위 코드: {}", parentCode);
return List.of();
}
try {
return commonCodeService.getFindAll().stream()
.filter(code -> parentCode.equals(code.getCode()))
.findFirst()
.map(Basic::getChildren)
.orElse(List.of());
} catch (Exception e) {
log.error("상위 코드 기반 하위 코드 조회 중 오류 발생: {}", parentCode, e);
return List.of();
}
}
/**
* 코드 사용 가능 여부 확인
*
* @param parentId 상위 코드 ID -> 1depth 는 null 허용 (파라미터 미필수로 변경)
* @param code 확인할 코드값
* @return 사용 가능 여부 (true: 사용 가능, false: 중복 또는 오류)
*/
public boolean isCodeAvailable(Long parentId, String code) {
if (parentId <= 0 || code == null || code.isEmpty()) {
log.warn("유효하지 않은 입력: parentId={}, code={}", parentId, code);
return false;
}
try {
ApiResponseDto.ResponseObj response = commonCodeService.getCodeCheckDuplicate(parentId, code);
// ResponseObj의 code 필드 : OK이면 성공, 아니면 실패
return response.getCode() != null
&& response.getCode().equals(ApiResponseDto.ApiResponseCode.OK);
} catch (Exception e) {
log.error("코드 중복 확인 중 오류 발생: parentId={}, code={}", parentId, code, e);
return false;
}
}
}

View File

@@ -0,0 +1,32 @@
package com.kamco.cd.training.common.utils;
import com.kamco.cd.training.auth.BCryptSaltGenerator;
import java.util.regex.Pattern;
import org.mindrot.jbcrypt.BCrypt;
public class CommonStringUtils {
/**
* 영문, 숫자, 특수문자를 모두 포함하여 8~20자 이내의 비밀번호
*
* @param password 벨리데이션 필요한 패스워드
* @return
*/
public static boolean isValidPassword(String password) {
String passwordPattern =
"^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-\\[\\]{};':\"\\\\|,.<>/?]).{8,20}$";
return Pattern.matches(passwordPattern, password);
}
/**
* 패스워드 암호화
*
* @param password 암호화 필요한 패스워드
* @param employeeNo salt 생성에 필요한 사원번호
* @return
*/
public static String hashPassword(String password, String employeeNo) {
String salt = BCryptSaltGenerator.generateSaltWithEmployeeNo(employeeNo.trim());
return BCrypt.hashpw(password.trim(), salt);
}
}

View File

@@ -0,0 +1,10 @@
package com.kamco.cd.training.common.utils;
import org.springframework.http.HttpStatus;
public interface ErrorCode {
String getCode();
HttpStatus getStatus();
}

View File

@@ -0,0 +1,685 @@
package com.kamco.cd.training.common.utils;
import static java.lang.String.CASE_INSENSITIVE_ORDER;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileTime;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.Getter;
import org.apache.commons.io.FilenameUtils;
import org.geotools.coverage.grid.GridCoverage2D;
import org.geotools.gce.geotiff.GeoTiffReader;
import org.springframework.util.FileSystemUtils;
import org.springframework.web.multipart.MultipartFile;
public class FIleChecker {
static SimpleDateFormat dttmFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static boolean isValidFile(String pathStr) {
Path path = Paths.get(pathStr);
if (!Files.exists(path)) {
return false;
}
if (!Files.isRegularFile(path)) {
return false;
}
if (!Files.isReadable(path)) {
return false;
}
try {
if (Files.size(path) <= 0) {
return false;
}
} catch (IOException e) {
return false;
}
return true;
}
public static boolean verifyFileIntegrity(Path path, String expectedHash)
throws IOException, NoSuchAlgorithmException {
// 1. 알고리즘 선택 (SHA-256 권장, MD5는 보안상 비추천)
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream fis = Files.newInputStream(path)) {
byte[] buffer = new byte[8192]; // 8KB 버퍼
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
}
// 3. 계산된 바이트 배열을 16진수 문자열로 변환
StringBuilder sb = new StringBuilder();
for (byte b : digest.digest()) {
sb.append(String.format("%02x", b));
}
String actualHash = sb.toString();
return actualHash.equalsIgnoreCase(expectedHash);
}
public static boolean checkTfw(String filePath) {
File file = new File(filePath);
if (!file.exists()) {
return false;
}
// 1. 파일의 모든 라인을 읽어옴
List<Double> lines = new ArrayList<>();
try (BufferedReader br = new BufferedReader(new FileReader(file))) {
String line;
while ((line = br.readLine()) != null) {
if (!line.trim().isEmpty()) { // 빈 줄 제외
lines.add(Double.parseDouble(line.trim()));
}
}
} catch (IOException ignored) {
return false;
}
// 2. 6줄이 맞는지 확인
if (lines.size() < 6) {
// System.out.println("유효하지 않은 TFW 파일입니다. (데이터 부족)");
return false;
}
return true;
}
public static boolean checkGeoTiff(String filePath) {
File file = new File(filePath);
if (!file.exists()) {
return false;
}
GeoTiffReader reader = null;
try {
// 1. 파일 포맷 및 헤더 확인
reader = new GeoTiffReader(file);
// 2. 실제 데이터 로딩 (여기서 파일 깨짐 여부 확인됨)
// null을 넣으면 전체 영역을 읽지 않고 메타데이터 위주로 체크하여 빠름
GridCoverage2D coverage = reader.read(null);
if (coverage == null) return false;
// 3. GIS 필수 정보(좌표계)가 있는지 확인
// if (coverage.getCoordinateReferenceSystem() == null) {
// GeoTIFF가 아니라 일반 TIFF일 수도 있음(이미지는 정상이지만, 좌표계(CRS) 정보가 없습니다.)
// }
return true;
} catch (Exception e) {
System.err.println("손상된 TIF 파일입니다: " + e.getMessage());
return false;
} finally {
// 리소스 해제 (필수)
if (reader != null) reader.dispose();
}
}
public static Boolean cmmndGdalInfo(String filePath) {
File file = new File(filePath);
if (!file.exists()) {
System.err.println("파일이 존재하지 않습니다: " + filePath);
return false;
}
boolean hasDriver = false;
// 운영체제 감지
String osName = System.getProperty("os.name").toLowerCase();
boolean isWindows = osName.contains("win");
boolean isMac = osName.contains("mac");
boolean isUnix = osName.contains("nix") || osName.contains("nux") || osName.contains("aix");
// gdalinfo 경로 찾기 (일반적인 설치 경로 우선 확인)
String gdalinfoPath = findGdalinfoPath();
if (gdalinfoPath == null) {
System.err.println("gdalinfo 명령어를 찾을 수 없습니다. GDAL이 설치되어 있는지 확인하세요.");
System.err.println("macOS: brew install gdal");
System.err.println("Ubuntu/Debian: sudo apt-get install gdal-bin");
System.err.println("CentOS/RHEL: sudo yum install gdal");
return false;
}
List<String> command = new ArrayList<>();
if (isWindows) {
// 윈도우용
command.add("cmd.exe"); // 윈도우 명령 프롬프트 실행
command.add("/c"); // 명령어를 수행하고 종료한다는 옵션
command.add("gdalinfo");
command.add(filePath);
command.add("|");
command.add("findstr");
command.add("/i");
command.add("Geo");
} else if (isMac || isUnix) {
// 리눅스, 맥용
command.add("sh");
command.add("-c");
command.add(gdalinfoPath + " \"" + filePath + "\" | grep -i Geo");
} else {
System.err.println("지원하지 않는 운영체제: " + osName);
return false;
}
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
Process process = null;
BufferedReader reader = null;
try {
System.out.println("gdalinfo 명령어 실행 시작: " + filePath);
process = processBuilder.start();
reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
// System.out.println("gdalinfo 출력: " + line);
if (line.contains("Driver: GTiff/GeoTIFF")) {
hasDriver = true;
break;
}
}
int exitCode = process.waitFor();
System.out.println("gdalinfo 종료 코드: " + exitCode);
// 프로세스가 정상 종료되지 않았고 Driver를 찾지 못한 경우
if (exitCode != 0 && !hasDriver) {
System.err.println("gdalinfo 명령 실행 실패. Exit code: " + exitCode);
}
} catch (IOException e) {
System.err.println("gdalinfo 실행 중 I/O 오류 발생: " + e.getMessage());
e.printStackTrace();
return false;
} catch (InterruptedException e) {
System.err.println("gdalinfo 실행 중 인터럽트 발생: " + e.getMessage());
Thread.currentThread().interrupt();
return false;
} catch (Exception e) {
System.err.println("gdalinfo 실행 중 예상치 못한 오류 발생: " + e.getMessage());
e.printStackTrace();
return false;
} finally {
// 리소스 정리
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
System.err.println("BufferedReader 종료 중 오류: " + e.getMessage());
}
}
if (process != null) {
process.destroy();
}
}
return hasDriver;
}
public static boolean mkDir(String dirPath) {
Path uploadTargetPath = Paths.get(dirPath);
try {
Files.createDirectories(uploadTargetPath);
} catch (IOException e) {
return false;
}
return true;
}
public static List<Folder> getFolderAll(String dirPath, String sortType, int maxDepth) {
Path startPath = Paths.get(dirPath);
List<Folder> folderList = List.of();
try (Stream<Path> stream = Files.walk(startPath, maxDepth)) {
folderList =
stream
.filter(Files::isDirectory)
.filter(p -> !p.toString().equals(dirPath))
.map(
path -> {
int depth = path.getNameCount();
String folderNm = path.getFileName().toString();
String parentFolderNm = path.getParent().getFileName().toString();
String parentPath = path.getParent().toString();
String fullPath = path.toAbsolutePath().toString();
boolean isValid =
!NameValidator.containsKorean(folderNm)
&& !NameValidator.containsWhitespaceRegex(folderNm);
File file = new File(fullPath);
int childCnt = getChildFolderCount(file);
String lastModified = getLastModified(file);
return new Folder(
folderNm,
parentFolderNm,
parentPath,
fullPath,
depth,
childCnt,
lastModified,
isValid);
})
.collect(Collectors.toList());
if (sortType.equals("name") || sortType.equals("name asc")) {
folderList.sort(
Comparator.comparing(
Folder::getFolderNm, CASE_INSENSITIVE_ORDER // 대소문자 구분 없이
));
} else if (sortType.equals("name desc")) {
folderList.sort(
Comparator.comparing(
Folder::getFolderNm, CASE_INSENSITIVE_ORDER // 대소문자 구분 없이
)
.reversed());
} else if (sortType.equals("dttm desc")) {
folderList.sort(
Comparator.comparing(
Folder::getLastModified, CASE_INSENSITIVE_ORDER // 대소문자 구분 없이
)
.reversed());
} else {
folderList.sort(
Comparator.comparing(
Folder::getLastModified, CASE_INSENSITIVE_ORDER // 대소문자 구분 없이
));
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return folderList;
}
public static List<Folder> getFolderAll(String dirPath) {
return getFolderAll(dirPath, "name", 1);
}
public static List<Folder> getFolderAll(String dirPath, String sortType) {
return getFolderAll(dirPath, sortType, 1);
}
public static int getChildFolderCount(String dirPath) {
File directory = new File(dirPath);
File[] childFolders = directory.listFiles(File::isDirectory);
int childCnt = 0;
if (childFolders != null) {
childCnt = childFolders.length;
}
return childCnt;
}
public static int getChildFolderCount(File directory) {
File[] childFolders = directory.listFiles(File::isDirectory);
int childCnt = 0;
if (childFolders != null) {
childCnt = childFolders.length;
}
return childCnt;
}
public static String getLastModified(String dirPath) {
File file = new File(dirPath);
return dttmFormat.format(new Date(file.lastModified()));
}
public static String getLastModified(File file) {
return dttmFormat.format(new Date(file.lastModified()));
}
public static List<Basic> getFilesFromAllDepth(
String dir,
String targetFileNm,
String extension,
int maxDepth,
String sortType,
int startPos,
int endPos) {
Path startPath = Paths.get(dir);
String dirPath = dir;
int limit = endPos - startPos + 1;
Set<String> targetExtensions = createExtensionSet(extension);
List<Basic> fileList = new ArrayList<>();
SimpleDateFormat dttmFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Predicate<Path> isTargetName =
p -> {
if (targetFileNm == null
|| targetFileNm.trim().isEmpty()
|| targetFileNm.trim().equals("*")) {
return true; // 전체 파일 허용
}
return p.getFileName().toString().contains(targetFileNm);
};
try (Stream<Path> stream = Files.walk(startPath, maxDepth)) {
fileList =
stream
.filter(Files::isRegularFile)
.filter(
p ->
extension == null
|| extension.equals("")
|| extension.equals("*")
|| targetExtensions.contains(extractExtension(p)))
.sorted(getFileComparator(sortType))
.filter(isTargetName)
.skip(startPos)
.limit(limit)
.map(
path -> {
// int depth = path.getNameCount();
String fileNm = path.getFileName().toString();
String ext = FilenameUtils.getExtension(fileNm);
String parentFolderNm = path.getParent().getFileName().toString();
String parentPath = path.getParent().toString();
String fullPath = path.toAbsolutePath().toString();
File file = new File(fullPath);
long fileSize = file.length();
String lastModified = dttmFormat.format(new Date(file.lastModified()));
return new Basic(
fileNm, parentFolderNm, parentPath, fullPath, ext, fileSize, lastModified);
})
.collect(Collectors.toList());
} catch (IOException e) {
System.err.println("파일 I/O 오류 발생: " + e.getMessage());
}
return fileList;
}
public static List<Basic> getFilesFromAllDepth(
String dir, String targetFileNm, String extension) {
return FIleChecker.getFilesFromAllDepth(dir, targetFileNm, extension, 100, "name", 0, 100);
}
public static int getFileCountFromAllDepth(String dir, String targetFileNm, String extension) {
List<FIleChecker.Basic> basicList =
FIleChecker.getFilesFromAllDepth(dir, targetFileNm, extension);
return (int)
basicList.stream().filter(dto -> dto.getExtension().toString().equals(extension)).count();
}
public static Long getFileTotSize(List<FIleChecker.Basic> files) {
Long fileTotSize = 0L;
if (files != null || files.size() > 0) {
fileTotSize = files.stream().mapToLong(FIleChecker.Basic::getFileSize).sum();
}
return fileTotSize;
}
public static boolean multipartSaveTo(MultipartFile mfile, String targetPath) {
Path tmpSavePath = Paths.get(targetPath);
boolean fileUpload = true;
try {
mfile.transferTo(tmpSavePath);
} catch (IOException e) {
// throw new RuntimeException(e);
return false;
}
return true;
}
public static boolean multipartChunkSaveTo(MultipartFile mfile, String targetPath, int chunkIndex) {
File dest = new File(targetPath, String.valueOf(chunkIndex));
boolean fileUpload = true;
try {
mfile.transferTo(dest);
} catch (IOException e) {
return false;
}
return true;
}
public static boolean deleteFolder(String path) {
return FileSystemUtils.deleteRecursively(new File(path));
}
public static boolean validationMultipart(MultipartFile mfile) {
// 파일 유효성 검증
if (mfile == null || mfile.isEmpty() || mfile.getSize() == 0) {
return false;
}
return true;
}
public static boolean checkExtensions(String fileName, String ext) {
if (fileName == null) return false;
if (!fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase().equals(ext)) {
return false;
}
return true;
}
public static Set<String> createExtensionSet(String extensionString) {
if (extensionString == null || extensionString.isBlank()) {
return Set.of();
}
// "java, class" -> ["java", " class"] -> [".java", ".class"]
return Arrays.stream(extensionString.split(","))
.map(ext -> ext.trim())
.filter(ext -> !ext.isEmpty())
.map(ext -> "." + ext.toLowerCase())
.collect(Collectors.toSet());
}
public static String extractExtension(Path path) {
String filename = path.getFileName().toString();
int lastDotIndex = filename.lastIndexOf('.');
// 확장자가 없거나 파일명이 .으로 끝나는 경우
if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) {
return ""; // 빈 문자열 반환
}
// 확장자 추출 및 소문자 변환
return filename.substring(lastDotIndex).toLowerCase();
}
public static Comparator<Path> getFileComparator(String sortType) {
// 파일 이름 비교 기본 Comparator (대소문자 무시)
Comparator<Path> nameComparator =
Comparator.comparing(path -> path.getFileName().toString(), CASE_INSENSITIVE_ORDER);
Comparator<Path> dateComparator =
Comparator.comparing(
path -> {
try {
return Files.getLastModifiedTime(path);
} catch (IOException e) {
return FileTime.fromMillis(0);
}
});
if ("name desc".equalsIgnoreCase(sortType)) {
return nameComparator.reversed();
} else if ("date".equalsIgnoreCase(sortType)) {
return dateComparator;
} else if ("date desc".equalsIgnoreCase(sortType)) {
return dateComparator.reversed();
} else {
return nameComparator;
}
}
private static String findGdalinfoPath() {
// 일반적인 설치 경로 확인
String[] possiblePaths = {
"/usr/local/bin/gdalinfo", // Homebrew (macOS)
"/opt/homebrew/bin/gdalinfo", // Homebrew (Apple Silicon macOS)
"/usr/bin/gdalinfo", // Linux
"gdalinfo" // PATH에 있는 경우
};
for (String path : possiblePaths) {
if (isCommandAvailable(path)) {
return path;
}
}
return null;
}
private static boolean isCommandAvailable(String command) {
try {
ProcessBuilder pb = new ProcessBuilder(command, "--version");
pb.redirectErrorStream(true);
Process process = pb.start();
// 프로세스 완료 대기 (최대 5초)
boolean finished = process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS);
if (!finished) {
process.destroy();
return false;
}
// 종료 코드가 0이면 정상 (일부 명령어는 --version에서 다른 코드 반환할 수 있음)
return process.exitValue() == 0 || process.exitValue() == 1;
} catch (Exception e) {
return false;
}
}
@Schema(name = "Folder", description = "폴더 정보")
@Getter
public static class Folder {
private final String folderNm;
private final String parentFolderNm;
private final String parentPath;
private final String fullPath;
private final int depth;
private final long childCnt;
private final String lastModified;
private final Boolean isValid;
public Folder(
String folderNm,
String parentFolderNm,
String parentPath,
String fullPath,
int depth,
long childCnt,
String lastModified,
Boolean isValid) {
this.folderNm = folderNm;
this.parentFolderNm = parentFolderNm;
this.parentPath = parentPath;
this.fullPath = fullPath;
this.depth = depth;
this.childCnt = childCnt;
this.lastModified = lastModified;
this.isValid = isValid;
}
}
@Schema(name = "File Basic", description = "파일 기본 정보")
@Getter
public static class Basic {
private final String fileNm;
private final String parentFolderNm;
private final String parentPath;
private final String fullPath;
private final String extension;
private final long fileSize;
private final String lastModified;
public Basic(
String fileNm,
String parentFolderNm,
String parentPath,
String fullPath,
String extension,
long fileSize,
String lastModified) {
this.fileNm = fileNm;
this.parentFolderNm = parentFolderNm;
this.parentPath = parentPath;
this.fullPath = fullPath;
this.extension = extension;
this.fileSize = fileSize;
this.lastModified = lastModified;
}
}
}

View File

@@ -0,0 +1,43 @@
package com.kamco.cd.training.common.utils;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class NameValidator {
private static final String HANGUL_REGEX = ".*\\p{IsHangul}.*";
private static final Pattern HANGUL_PATTERN = Pattern.compile(HANGUL_REGEX);
private static final String WHITESPACE_REGEX = ".*\\s.*";
private static final Pattern WHITESPACE_PATTERN = Pattern.compile(WHITESPACE_REGEX);
public static boolean containsKorean(String str) {
if (str == null || str.isEmpty()) {
return false;
}
Matcher matcher = HANGUL_PATTERN.matcher(str);
return matcher.matches();
}
public static boolean containsWhitespaceRegex(String str) {
if (str == null || str.isEmpty()) {
return false;
}
Matcher matcher = WHITESPACE_PATTERN.matcher(str);
// find()를 사용하여 문자열 내에서 패턴이 일치하는 부분이 있는지 확인
return matcher.find();
}
public static boolean isNullOrEmpty(String str) {
if (str == null) {
return true;
}
if (str.isEmpty()) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,41 @@
package com.kamco.cd.training.common.utils;
import com.kamco.cd.training.auth.CustomUserDetails;
import com.kamco.cd.training.members.dto.MembersDto;
import com.kamco.cd.training.postgres.entity.MemberEntity;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class UserUtil {
public MembersDto.Member getCurrentUser() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(auth -> auth.getPrincipal() instanceof CustomUserDetails)
.map(
auth -> {
CustomUserDetails user = (CustomUserDetails) auth.getPrincipal();
MemberEntity m = user.getMember();
return new MembersDto.Member(m.getId(), m.getName(), m.getEmployeeNo());
})
.orElse(null);
}
public Long getId() {
MembersDto.Member user = getCurrentUser();
return user != null ? user.getId() : null;
}
public String getName() {
MembersDto.Member user = getCurrentUser();
return user != null ? user.getName() : null;
}
public String getEmployeeNo() {
MembersDto.Member user = getCurrentUser();
return user != null ? user.getEmployeeNo() : null;
}
}

View File

@@ -0,0 +1,20 @@
package com.kamco.cd.training.common.utils.enums;
public class CodeDto {
private String code;
private String name;
public CodeDto(String code, String name) {
this.code = code;
this.name = name;
}
public String getCode() {
return code;
}
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,10 @@
package com.kamco.cd.training.common.utils.enums;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CodeExpose {}

View File

@@ -0,0 +1,8 @@
package com.kamco.cd.training.common.utils.enums;
public interface EnumType {
String getId();
String getText();
}

View File

@@ -0,0 +1,26 @@
package com.kamco.cd.training.common.utils.enums;
import com.kamco.cd.training.common.utils.interfaces.EnumValid;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
public class EnumValidator implements ConstraintValidator<EnumValid, String> {
private Set<String> acceptedValues;
@Override
public void initialize(EnumValid constraintAnnotation) {
acceptedValues =
Arrays.stream(constraintAnnotation.enumClass().getEnumConstants())
.map(Enum::name)
.collect(Collectors.toSet());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value != null && acceptedValues.contains(value);
}
}

View File

@@ -0,0 +1,76 @@
package com.kamco.cd.training.common.utils.enums;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.reflections.Reflections;
public class Enums {
private static final String BASE_PACKAGE = "com.kamco.cd.training";
/** 노출 가능한 enum만 모아둔 맵 key: enum simpleName (예: RoleType) value: enum Class */
private static final Map<String, Class<? extends Enum<?>>> exposedEnumMap = scanExposedEnumMap();
// code로 enum 찾기
public static <E extends Enum<E> & EnumType> E fromId(Class<E> enumClass, String id) {
if (id == null) {
return null;
}
for (E e : enumClass.getEnumConstants()) {
if (id.equalsIgnoreCase(e.getId())) {
return e;
}
}
return null;
}
// enum -> CodeDto list
public static List<CodeDto> toList(Class<? extends Enum<?>> enumClass) {
Object[] enums = enumClass.getEnumConstants();
return Arrays.stream(enums)
.map(e -> (EnumType) e)
.map(e -> new CodeDto(e.getId(), e.getText()))
.toList();
}
/** 특정 타입(enum)만 조회 /codes/{type} -> type = RoleType 같은 값 */
public static List<CodeDto> getCodes(String type) {
Class<? extends Enum<?>> enumClass = exposedEnumMap.get(type);
if (enumClass == null) {
throw new IllegalArgumentException("지원하지 않는 코드 타입: " + type);
}
return toList(enumClass);
}
/** 전체 enum 코드 조회 */
public static Map<String, List<CodeDto>> getAllCodes() {
Map<String, List<CodeDto>> result = new HashMap<>();
for (Map.Entry<String, Class<? extends Enum<?>>> e : exposedEnumMap.entrySet()) {
result.put(e.getKey(), toList(e.getValue()));
}
return result;
}
/**
* @CodeExpose + EnumType 인 enum만 스캔해서 Map 구성
*/
private static Map<String, Class<? extends Enum<?>>> scanExposedEnumMap() {
Reflections reflections = new Reflections(BASE_PACKAGE);
Set<Class<?>> types = reflections.getTypesAnnotatedWith(CodeExpose.class);
Map<String, Class<? extends Enum<?>>> result = new HashMap<>();
for (Class<?> clazz : types) {
if (clazz.isEnum() && EnumType.class.isAssignableFrom(clazz)) {
result.put(clazz.getSimpleName(), (Class<? extends Enum<?>>) clazz);
}
}
return result;
}
}

View File

@@ -0,0 +1,36 @@
package com.kamco.cd.training.common.utils.geometry;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import java.io.IOException;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.geojson.GeoJsonReader;
import org.springframework.util.StringUtils;
public class GeometryDeserializer<T extends Geometry> extends StdDeserializer<T> {
public GeometryDeserializer(Class<T> targetType) {
super(targetType);
}
// TODO: test code
@SuppressWarnings("unchecked")
@Override
public T deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)
throws IOException, JacksonException {
String json = jsonParser.readValueAsTree().toString();
if (!StringUtils.hasText(json)) {
return null;
}
try {
GeoJsonReader reader = new GeoJsonReader();
return (T) reader.read(json);
} catch (Exception e) {
throw new IllegalArgumentException("Failed to deserialize GeoJSON into Geometry", e);
}
}
}

View File

@@ -0,0 +1,31 @@
package com.kamco.cd.training.common.utils.geometry;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import java.util.Objects;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.geojson.GeoJsonWriter;
public class GeometrySerializer<T extends Geometry> extends StdSerializer<T> {
// TODO: test code
public GeometrySerializer(Class<T> targetType) {
super(targetType);
}
@Override
public void serialize(
T geometry, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
throws IOException {
if (Objects.nonNull(geometry)) {
// default: 8자리 강제로 반올림시킴. 16자리로 늘려줌
GeoJsonWriter writer = new GeoJsonWriter(16);
String json = writer.write(geometry);
jsonGenerator.writeRawValue(json);
} else {
jsonGenerator.writeNull();
}
}
}

View File

@@ -0,0 +1,18 @@
package com.kamco.cd.training.common.utils.html;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException;
import org.springframework.web.util.HtmlUtils;
public class HtmlEscapeDeserializer extends JsonDeserializer<Object> {
@Override
public Object deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)
throws IOException, JacksonException {
String value = jsonParser.getValueAsString();
return value == null ? null : HtmlUtils.htmlEscape(value);
}
}

View File

@@ -0,0 +1,20 @@
package com.kamco.cd.training.common.utils.html;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
import org.springframework.web.util.HtmlUtils;
public class HtmlUnescapeSerializer extends JsonSerializer<String> {
@Override
public void serialize(
String value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
throws IOException {
if (value == null) {
jsonGenerator.writeNull();
} else {
jsonGenerator.writeString(HtmlUtils.htmlUnescape(value));
}
}
}

View File

@@ -0,0 +1,23 @@
package com.kamco.cd.training.common.utils.interfaces;
import com.kamco.cd.training.common.utils.enums.EnumValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface EnumValid {
String message() default "올바르지 않은 값입니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<? extends Enum<?>> enumClass();
}

View File

@@ -0,0 +1,15 @@
package com.kamco.cd.training.common.utils.interfaces;
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@JacksonAnnotationsInside
@JsonFormat(
shape = JsonFormat.Shape.STRING,
pattern = "yyyy-MM-dd'T'HH:mm:ssXXX",
timezone = "Asia/Seoul")
public @interface JsonFormatDttm {}

View File

@@ -0,0 +1,11 @@
package com.kamco.cd.training.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
@EnableCaching
@Configuration
public class CacheConfig {
// training 서버는 Redis 사용하지 않고 Spring Boot 메모리 캐시를 사용함
// => org.springframework.cache.annotation.Cacheable
}

View File

@@ -0,0 +1,27 @@
package com.kamco.cd.training.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/** GeoJSON 파일 모니터링 설정 */
@Component
@ConfigurationProperties(prefix = "file.config")
@Getter
@Setter
public class FileConfig {
// private String rootDir = "D:\\app/";
private String rootDir = "/app/";
private String rootSyncDir = rootDir + "original-images/";
private String tmpSyncDir = rootSyncDir + "tmp/";
private String dataSetDir = rootDir + "dataset/";
private String tmpDataSetDir = dataSetDir + "tmp/";
// private String rootSyncDir = "/app/original-images/";
// private String tmpSyncDir = rootSyncDir + "tmp/";
private String syncFileExt = "tfw,tif";
}

View File

@@ -0,0 +1,528 @@
package com.kamco.cd.training.config;
import com.kamco.cd.training.auth.CustomUserDetails;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.common.exception.DuplicateFileException;
import com.kamco.cd.training.config.api.ApiLogFunction;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.config.api.ApiResponseDto.ApiResponseCode;
import com.kamco.cd.training.log.dto.ErrorLogDto;
import com.kamco.cd.training.members.exception.MemberException;
import com.kamco.cd.training.postgres.entity.ErrorLogEntity;
import com.kamco.cd.training.postgres.repository.log.ErrorLogRepository;
import io.micrometer.core.instrument.config.validate.ValidationException;
import jakarta.persistence.EntityNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.file.AccessDeniedException;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.NoSuchElementException;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpServerErrorException;
@Slf4j
@Order(value = 1)
@RestControllerAdvice
public class GlobalExceptionHandler {
private final ErrorLogRepository errorLogRepository;
public GlobalExceptionHandler(ErrorLogRepository errorLogRepository) {
this.errorLogRepository = errorLogRepository;
}
@ResponseStatus(HttpStatus.CONFLICT)
@ExceptionHandler(DuplicateFileException.class)
public ApiResponseDto<String> handleDuplicateFileException(
DuplicateFileException e, HttpServletRequest request) {
log.warn("[DuplicateFileException] resource :{} ", e.getMessage());
this.errorLog(request, e);
ApiResponseCode code = ApiResponseCode.CONFLICT;
ErrorLogEntity errorLog =
saveErrorLogData(
request,
code,
HttpStatus.CONFLICT,
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
code, e.getMessage(), HttpStatus.CONFLICT, errorLog.getId());
}
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
@ExceptionHandler(ValidationException.class)
public ApiResponseDto<String> handleValidationException(
ValidationException e, HttpServletRequest request) {
log.warn("[ValidationException] resource :{} ", e.getMessage());
this.errorLog(request, e);
ApiResponseCode code = ApiResponseCode.UNPROCESSABLE_ENTITY;
ErrorLogEntity errorLog =
saveErrorLogData(
request,
code,
HttpStatus.UNPROCESSABLE_ENTITY,
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
code, e.getMessage(), HttpStatus.UNPROCESSABLE_ENTITY, errorLog.getId());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ApiResponseDto<String> handleIllegalArgumentException(
IllegalArgumentException e, HttpServletRequest request) {
log.warn("[IllegalArgumentException] resource :{} ", e.getMessage());
this.errorLog(request, e);
ApiResponseCode code = ApiResponseCode.BAD_REQUEST;
ErrorLogEntity errorLog =
saveErrorLogData(
request,
code,
HttpStatus.BAD_REQUEST,
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
code, e.getMessage(), HttpStatus.BAD_REQUEST, errorLog.getId());
}
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
@ExceptionHandler(EntityNotFoundException.class)
public ApiResponseDto<String> handlerEntityNotFoundException(
EntityNotFoundException e, HttpServletRequest request) {
log.warn("[EntityNotFoundException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "NOT_FOUND_DATA";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf("UNPROCESSABLE_ENTITY"),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf("UNPROCESSABLE_ENTITY"),
errorLog.getId());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResponseDto<String> handleUnreadable(
HttpMessageNotReadableException e, HttpServletRequest request) {
log.warn("[HttpMessageNotReadableException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "BAD_REQUEST";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(codeName),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf(codeName),
errorLog.getId());
}
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(NoSuchElementException.class)
public ApiResponseDto<String> handlerNoSuchElementException(
NoSuchElementException e, HttpServletRequest request) {
log.warn("[NoSuchElementException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "NOT_FOUND_DATA";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(codeName),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf("NOT_FOUND"),
errorLog.getId());
}
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
@ExceptionHandler(DataIntegrityViolationException.class)
public ApiResponseDto<String> handlerDataIntegrityViolationException(
DataIntegrityViolationException e, HttpServletRequest request) {
log.warn("[DataIntegrityViolationException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "DATA_INTEGRITY_ERROR";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf("UNPROCESSABLE_ENTITY"),
ErrorLogDto.LogErrorLevel.CRITICAL,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf("UNPROCESSABLE_ENTITY"),
errorLog.getId());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponseDto<String> handlerMethodArgumentNotValidException(
MethodArgumentNotValidException e, HttpServletRequest request) {
log.warn("[MethodArgumentNotValidException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "BAD_REQUEST";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(codeName),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf(codeName),
errorLog.getId());
}
@ResponseStatus(HttpStatus.FORBIDDEN)
@ExceptionHandler(AccessDeniedException.class)
public ApiResponseDto<String> handlerAccessDeniedException(
AccessDeniedException e, HttpServletRequest request) {
log.warn("[AccessDeniedException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "FORBIDDEN";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(codeName),
ErrorLogDto.LogErrorLevel.ERROR,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf(codeName),
errorLog.getId());
}
@ResponseStatus(HttpStatus.BAD_GATEWAY)
@ExceptionHandler(HttpServerErrorException.BadGateway.class)
public ApiResponseDto<String> handlerHttpServerErrorException(
HttpServerErrorException e, HttpServletRequest request) {
log.warn("[HttpServerErrorException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "BAD_GATEWAY";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(codeName),
ErrorLogDto.LogErrorLevel.CRITICAL,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf(codeName),
errorLog.getId());
}
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
@ExceptionHandler(IllegalStateException.class)
public ApiResponseDto<String> handlerIllegalStateException(
IllegalStateException e, HttpServletRequest request) {
log.warn("[IllegalStateException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "UNPROCESSABLE_ENTITY";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(codeName),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf(codeName),
errorLog.getId());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MemberException.DuplicateMemberException.class)
public ApiResponseDto<String> handlerDuplicateMemberException(
MemberException.DuplicateMemberException e, HttpServletRequest request) {
log.warn("[DuplicateMemberException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "";
switch (e.getField()) {
case EMPLOYEE_NO -> {
codeName = "DUPLICATE_EMPLOYEEID";
}
default -> {
codeName = "DUPLICATE_DATA";
}
}
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf("BAD_REQUEST"),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf("BAD_REQUEST"),
errorLog.getId());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MemberException.MemberNotFoundException.class)
public ApiResponseDto<String> handlerMemberNotFoundException(
MemberException.MemberNotFoundException e, HttpServletRequest request) {
log.warn("[MemberNotFoundException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "NOT_FOUND_USER";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf("BAD_REQUEST"),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf("BAD_REQUEST"),
errorLog.getId());
}
@ResponseStatus(HttpStatus.CONFLICT)
@ExceptionHandler(DuplicateKeyException.class)
public ApiResponseDto<String> handlerDuplicateKeyException(
DuplicateKeyException e, HttpServletRequest request) {
log.warn("[DuplicateKeyException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "DUPLICATE_DATA";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf("CONFLICT"),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf("CONFLICT"),
errorLog.getId());
}
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiResponseDto<String>> handleBadCredentials(
BadCredentialsException e, HttpServletRequest request) {
log.warn("[BadCredentialsException] resource : {} ", e.getMessage());
this.errorLog(request, e);
String codeName = "UNAUTHORIZED";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(codeName),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
ApiResponseDto<String> body =
ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf(codeName),
errorLog.getId());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) // 🔥 여기서 401 지정
.body(body);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(RuntimeException.class)
public ApiResponseDto<String> handlerRuntimeException(
RuntimeException e, HttpServletRequest request) {
log.warn("[RuntimeException] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "INTERNAL_SERVER_ERROR";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(codeName),
ErrorLogDto.LogErrorLevel.CRITICAL,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf(codeName),
errorLog.getId());
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public ApiResponseDto<String> handlerException(Exception e, HttpServletRequest request) {
log.warn("[Exception] resource :{} ", e.getMessage());
this.errorLog(request, e);
String codeName = "INTERNAL_SERVER_ERROR";
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(codeName),
ErrorLogDto.LogErrorLevel.CRITICAL,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf(codeName),
errorLog.getId());
}
/**
* 에러 로그 테이블 저장 로직
*
* @param request : request
* @param errorCode : 정의된 enum errorCode
* @param httpStatus : HttpStatus 값
* @param logErrorLevel : WARNING, ERROR, CRITICAL
* @param stackTrace : 에러 내용
* @return : insert하고 결과로 받은 Entity
*/
private ErrorLogEntity saveErrorLogData(
HttpServletRequest request,
ApiResponseCode errorCode,
HttpStatus httpStatus,
ErrorLogDto.LogErrorLevel logErrorLevel,
StackTraceElement[] stackTrace) {
Long userid = null;
/**
* servletRequest.getUserPrincipal() instanceof UsernamePasswordAuthenticationToken auth 이 요청이
* JWT 인증을 통과한 요청인가? 그리고 Spring Security Authentication 객체가 UsernamePasswordAuthenticationToken
* 타입인가? 체크
*/
/**
* auth.getPrincipal() instanceof CustomUserDetails customUserDetails principal 안에 들어있는 객체가 내가
* 만든 CustomUserDetails 타입인가? 체크
*/
if (request.getUserPrincipal() instanceof UsernamePasswordAuthenticationToken auth
&& auth.getPrincipal() instanceof CustomUserDetails customUserDetails) {
// audit 에는 long 타입 user_id가 들어가지만 토큰 sub은 uuid여서 user_id 가져오기
userid = customUserDetails.getMember().getId();
}
String stackTraceStr =
Arrays.stream(stackTrace)
.map(StackTraceElement::toString)
.collect(Collectors.joining("\n"))
.substring(0, Math.min(stackTrace.length, 255));
ErrorLogEntity errorLogEntity =
new ErrorLogEntity(
request.getRequestURI(),
ApiLogFunction.getEventType(request),
logErrorLevel,
String.valueOf(httpStatus.value()),
errorCode.getText(),
stackTraceStr,
userid,
ZonedDateTime.now());
return errorLogRepository.save(errorLogEntity);
}
@ExceptionHandler(CustomApiException.class)
public ApiResponseDto<String> handleCustomApiException(
CustomApiException e, HttpServletRequest request) {
log.warn("[CustomApiException] resource : {}", e.getMessage());
this.errorLog(request, e);
String codeName = e.getCodeName();
HttpStatus status = e.getStatus();
// String message = e.getMessage() == null ? ApiResponseCode.getMessage(codeName) :
// e.getMessage();
//
// ApiResponseCode apiCode = ApiResponseCode.getCode(codeName);
//
// ErrorLogEntity errorLog =
// saveErrorLogData(
// request, apiCode, status, ErrorLogDto.LogErrorLevel.WARNING, e.getStackTrace());
//
// ApiResponseDto<String> body =
// ApiResponseDto.createException(apiCode, message, status, errorLog.getId());
ErrorLogEntity errorLog =
saveErrorLogData(
request,
ApiResponseCode.getCode(codeName),
HttpStatus.valueOf(status.value()),
ErrorLogDto.LogErrorLevel.WARNING,
e.getStackTrace());
return ApiResponseDto.createException(
ApiResponseCode.getCode(codeName),
ApiResponseCode.getMessage(codeName),
HttpStatus.valueOf(status.value()),
errorLog.getId());
// return new ResponseEntity<>(body, status);
}
private void errorLog(HttpServletRequest request, Throwable e) {
String uri = request != null ? request.getRequestURI() : "N/A";
// 예외 타입명 (IllegalArgumentException, BadCredentialsException 등)
String exceptionName = e.getClass().getSimpleName();
log.error("[{}] uri={}, message={}", exceptionName, uri, e.getMessage());
log.error("[{}] stacktrace", exceptionName, e);
}
}

View File

@@ -0,0 +1,77 @@
package com.kamco.cd.training.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Value("${swagger.local-port}")
private String localPort;
@Value("${spring.profiles.active:local}")
private String profile;
@Value("${swagger.dev-url:https://kamco.training-dev-api.gs.dabeeo.com}")
private String devUrl;
@Value("${swagger.prod-url:https://api.training-kamco.com}")
private String prodUrl;
@Bean
public OpenAPI kamcoOpenAPI() {
// 1) SecurityScheme 정의 (Bearer JWT)
SecurityScheme bearerAuth =
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
.name("Authorization");
// 2) SecurityRequirement (기본으로 BearerAuth 사용)
SecurityRequirement securityRequirement = new SecurityRequirement().addList("BearerAuth");
// 3) Components 에 SecurityScheme 등록
Components components = new Components().addSecuritySchemes("BearerAuth", bearerAuth);
// profile 별 server url 분기
List<Server> servers = new ArrayList<>();
if ("dev".equals(profile)) {
servers.add(new Server().url(devUrl).description("개발 서버"));
servers.add(new Server().url("http://localhost:" + localPort).description("로컬 서버"));
// servers.add(new Server().url(prodUrl).description("운영 서버"));
} else if ("prod".equals(profile)) {
// servers.add(new Server().url(prodUrl).description("운영 서버"));
servers.add(new Server().url("http://localhost:" + localPort).description("로컬 서버"));
servers.add(new Server().url(devUrl).description("개발 서버"));
} else {
servers.add(new Server().url("http://localhost:" + localPort).description("로컬 서버"));
servers.add(new Server().url(devUrl).description("개발 서버"));
// servers.add(new Server().url(prodUrl).description("운영 서버"));
}
return new OpenAPI()
.info(
new Info()
.title("KAMCO Change Detection API")
.description(
"KAMCO 변화 탐지 시스템 API 문서\n\n"
+ "이 API는 지리공간 데이터를 활용한 변화 탐지 시스템을 제공합니다.\n"
+ "GeoJSON 형식의 공간 데이터를 처리하며, PostgreSQL/PostGIS 기반으로 동작합니다.")
.version("v1.0.0"))
.servers(servers)
// 만들어둔 components를 넣어야 함
.components(components)
.addSecurityItem(securityRequirement);
}
}

View File

@@ -0,0 +1,136 @@
package com.kamco.cd.training.config;
import com.kamco.cd.training.auth.CustomAuthenticationProvider;
import com.kamco.cd.training.auth.JwtAuthenticationFilter;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
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.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
org.springframework.security.config.annotation.web.builders.HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter,
CustomAuthenticationProvider customAuthenticationProvider)
throws Exception {
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(form -> form.disable())
// /monitor 에서 Basic 인증을 쓰려면 disable 하면 안됨
.httpBasic(basic -> {})
.logout(logout -> logout.disable())
.authenticationProvider(customAuthenticationProvider)
.authorizeHttpRequests(
auth ->
auth
// monitor
.requestMatchers("/monitor/health", "/monitor/health/**")
.permitAll()
.requestMatchers("/monitor/**")
.authenticated() // Basic으로 인증되게끔
// mapsheet
.requestMatchers("/api/mapsheet/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/mapsheet/upload")
.permitAll()
// test role
.requestMatchers("/api/test/admin")
.hasRole("ADMIN")
.requestMatchers("/api/test/label")
.hasAnyRole("ADMIN", "LABELER")
.requestMatchers("/api/test/review")
.hasAnyRole("ADMIN", "REVIEWER")
// common permit
.requestMatchers("/error")
.permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()
.requestMatchers(
"/api/auth/signin",
"/api/auth/refresh",
"/api/auth/logout",
"/swagger-ui/**",
"/v3/api-docs/**",
"/api/members/*/password",
"/api/upload/chunk-upload-dataset",
"/api/upload/chunk-upload-complete")
.permitAll()
// default
.anyRequest()
.authenticated())
// JWT 필터는 앞단에
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/** CORS 설정 */
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration(); // CORS 객체 생성
config.setAllowedOriginPatterns(List.of("*")); // 도메인 허용
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*")); // 헤더요청 Authorization, Content-Type, X-Custom-Header
config.setAllowCredentials(true); // 쿠키, Authorization 헤더, Bearer Token 등 자격증명 포함 요청을 허용할지 설정
config.setExposedHeaders(List.of("Content-Disposition"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
/** "/**" → 모든 API 경로에 대해 이 CORS 규칙을 적용 /api/** 같이 특정 경로만 지정 가능. */
source.registerCorsConfiguration("/**", config); // CORS 정책을 등록
return source;
}
@Bean
public HttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setAllowUrlEncodedSlash(true);
firewall.setAllowUrlEncodedDoubleSlash(true);
firewall.setAllowUrlEncodedPercent(true);
firewall.setAllowSemicolon(true);
return firewall;
}
/** 완전 제외(필터 자체를 안 탐) */
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers("/api/mapsheet/**");
}
}

View File

@@ -0,0 +1,96 @@
package com.kamco.cd.training.config;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class StartupLogger {
private final Environment environment;
private final DataSource dataSource;
@EventListener(ApplicationReadyEvent.class)
public void logStartupInfo() {
String[] activeProfiles = environment.getActiveProfiles();
String profileInfo = activeProfiles.length > 0 ? String.join(", ", activeProfiles) : "default";
// Database connection information
String dbUrl = environment.getProperty("spring.datasource.url");
String dbUsername = environment.getProperty("spring.datasource.username");
String dbDriver = environment.getProperty("spring.datasource.driver-class-name");
// HikariCP pool settings
String poolInfo = "";
if (dataSource instanceof HikariDataSource hikariDs) {
poolInfo =
String.format(
"""
│ Pool Size : min=%d, max=%d
│ Connection Timeout: %dms
│ Idle Timeout : %dms
│ Max Lifetime : %dms""",
hikariDs.getMinimumIdle(),
hikariDs.getMaximumPoolSize(),
hikariDs.getConnectionTimeout(),
hikariDs.getIdleTimeout(),
hikariDs.getMaxLifetime());
}
// JPA/Hibernate settings
String showSql = environment.getProperty("spring.jpa.show-sql", "false");
String ddlAuto = environment.getProperty("spring.jpa.hibernate.ddl-auto", "none");
String batchSize =
environment.getProperty("spring.jpa.properties.hibernate.jdbc.batch_size", "N/A");
String batchFetchSize =
environment.getProperty("spring.jpa.properties.hibernate.default_batch_fetch_size", "N/A");
String startupMessage =
String.format(
"""
╔════════════════════════════════════════════════════════════════════════════════╗
║ 🚀 APPLICATION STARTUP INFORMATION ║
╠════════════════════════════════════════════════════════════════════════════════╣
║ PROFILE CONFIGURATION ║
╠────────────────────────────────────────────────────────────────────────────────╣
│ Active Profile(s): %s
╠════════════════════════════════════════════════════════════════════════════════╣
║ DATABASE CONFIGURATION ║
╠────────────────────────────────────────────────────────────────────────────────╣
│ Database URL : %s
│ Username : %s
│ Driver : %s
╠════════════════════════════════════════════════════════════════════════════════╣
║ HIKARICP CONNECTION POOL ║
╠────────────────────────────────────────────────────────────────────────────────╣
%s
╠════════════════════════════════════════════════════════════════════════════════╣
║ JPA/HIBERNATE CONFIGURATION ║
╠────────────────────────────────────────────────────────────────────────────────╣
│ Show SQL : %s
│ DDL Auto : %s
│ JDBC Batch Size : %s
│ Fetch Batch Size : %s
╚════════════════════════════════════════════════════════════════════════════════╝
""",
profileInfo,
dbUrl != null ? dbUrl : "N/A",
dbUsername != null ? dbUsername : "N/A",
dbDriver != null ? dbDriver : "PostgreSQL JDBC Driver (auto-detected)",
poolInfo,
showSql,
ddlAuto,
batchSize,
batchFetchSize);
log.info(startupMessage);
}
}

View File

@@ -0,0 +1,13 @@
package com.kamco.cd.training.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springframework.context.annotation.Configuration;
@Configuration
@SecurityScheme(
name = "BearerAuth",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT")
public class SwaggerConfig {}

View File

@@ -0,0 +1,32 @@
package com.kamco.cd.training.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.kamco.cd.training.common.utils.geometry.GeometryDeserializer;
import com.kamco.cd.training.common.utils.geometry.GeometrySerializer;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public ObjectMapper objectMapper() {
SimpleModule module = new SimpleModule();
module.addSerializer(Geometry.class, new GeometrySerializer<>(Geometry.class));
module.addDeserializer(Geometry.class, new GeometryDeserializer<>(Geometry.class));
module.addSerializer(Polygon.class, new GeometrySerializer<>(Polygon.class));
module.addDeserializer(Polygon.class, new GeometryDeserializer<>(Polygon.class));
module.addSerializer(Point.class, new GeometrySerializer<>(Point.class));
module.addDeserializer(Point.class, new GeometryDeserializer<>(Point.class));
return Jackson2ObjectMapperBuilder.json().modulesToInstall(module).build();
}
}

View File

@@ -0,0 +1,28 @@
package com.kamco.cd.training.config.api;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
@Component
public class ApiLogFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);
filterChain.doFilter(wrappedRequest, wrappedResponse);
// 반드시 response body copy
wrappedResponse.copyBodyToResponse();
}
}

View File

@@ -0,0 +1,132 @@
package com.kamco.cd.training.config.api;
import com.kamco.cd.training.log.dto.EventStatus;
import com.kamco.cd.training.log.dto.EventType;
import com.kamco.cd.training.menu.dto.MenuDto;
import jakarta.servlet.http.HttpServletRequest;
import java.io.UnsupportedEncodingException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.web.util.ContentCachingRequestWrapper;
public class ApiLogFunction {
// 클라이언트 IP 추출
public static String getClientIp(HttpServletRequest request) {
String[] headers = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_CLIENT_IP",
"HTTP_X_FORWARDED_FOR"
};
for (String header : headers) {
String ip = request.getHeader(header);
if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
return ip.split(",")[0];
}
}
String ip = request.getRemoteAddr();
if ("0:0:0:0:0:0:0:1".equals(ip)) { // local 일 때
ip = "127.0.0.1";
}
return ip;
}
// 사용자 ID 추출 예시 (Spring Security 기준)
public static String getUserId(HttpServletRequest request) {
try {
return request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : null;
} catch (Exception e) {
return null;
}
}
public static EventType getEventType(HttpServletRequest request) {
String method = request.getMethod().toUpperCase();
String uri = request.getRequestURI().toLowerCase();
// URL 기반 DOWNLOAD/PRINT 분류
if (uri.contains("/download") || uri.contains("/export")) {
return EventType.DOWNLOAD;
}
if (uri.contains("/print")) {
return EventType.PRINT;
}
// 일반 CRUD
return switch (method) {
case "POST" -> EventType.CREATE;
case "GET" -> EventType.READ;
case "DELETE" -> EventType.DELETE;
case "PUT", "PATCH" -> EventType.UPDATE;
default -> EventType.OTHER;
};
}
public static String getRequestBody(
HttpServletRequest servletRequest, ContentCachingRequestWrapper contentWrapper) {
StringBuilder resultBody = new StringBuilder();
// GET, form-urlencoded POST 파라미터
Map<String, String[]> paramMap = servletRequest.getParameterMap();
String queryParams =
paramMap.entrySet().stream()
.map(e -> e.getKey() + "=" + String.join(",", e.getValue()))
.collect(Collectors.joining("&"));
resultBody.append(queryParams.isEmpty() ? "" : queryParams);
// JSON Body
if ("POST".equalsIgnoreCase(servletRequest.getMethod())
&& servletRequest.getContentType() != null
&& servletRequest.getContentType().contains("application/json")) {
try {
// json인 경우는 Wrapper를 통해 가져오기
resultBody.append(getBodyData(contentWrapper));
} catch (Exception e) {
resultBody.append("cannot read JSON body ").append(e.toString());
}
}
// Multipart form-data
if ("POST".equalsIgnoreCase(servletRequest.getMethod())
&& servletRequest.getContentType() != null
&& servletRequest.getContentType().startsWith("multipart/form-data")) {
resultBody.append("multipart/form-data request");
}
return resultBody.toString();
}
// JSON Body 읽기
public static String getBodyData(ContentCachingRequestWrapper request) {
byte[] buf = request.getContentAsByteArray();
if (buf.length == 0) {
return null;
}
try {
return new String(buf, request.getCharacterEncoding());
} catch (UnsupportedEncodingException e) {
return new String(buf);
}
}
// ApiResponse 의 Status가 2xx 범위이면 SUCCESS, 아니면 FAILED
public static EventStatus isSuccessFail(ApiResponseDto<?> apiResponse) {
return apiResponse.getHttpStatus().is2xxSuccessful() ? EventStatus.SUCCESS : EventStatus.FAILED;
}
public static String getUriMenuInfo(List<MenuDto.Basic> menuList, String uri) {
MenuDto.Basic m =
menuList.stream()
.filter(menu -> menu.getMenuApiUrl() != null && uri.contains(menu.getMenuApiUrl()))
.findFirst()
.orElse(null);
return m != null ? m.getMenuUid() : "SYSTEM";
}
}

View File

@@ -0,0 +1,124 @@
package com.kamco.cd.training.config.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kamco.cd.training.auth.CustomUserDetails;
import com.kamco.cd.training.menu.service.MenuService;
import com.kamco.cd.training.postgres.entity.AuditLogEntity;
import com.kamco.cd.training.postgres.repository.log.AuditLogRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import org.springframework.web.util.ContentCachingRequestWrapper;
/**
* ApiResponseDto의 내장된 HTTP 상태 코드를 실제 HTTP 응답에 적용하는 Advice
*
* <p>createOK() → 201 CREATED ok() → 200 OK deleteOk() → 204 NO_CONTENT
*/
@RestControllerAdvice
public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {
private final AuditLogRepository auditLogRepository;
private final MenuService menuService;
@Autowired private ObjectMapper objectMapper;
public ApiResponseAdvice(AuditLogRepository auditLogRepository, MenuService menuService) {
this.auditLogRepository = auditLogRepository;
this.menuService = menuService;
}
@Override
public boolean supports(
MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// ApiResponseDto를 반환하는 경우에만 적용
return returnType.getParameterType().equals(ApiResponseDto.class);
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
ContentCachingRequestWrapper contentWrapper = null;
if (servletRequest instanceof ContentCachingRequestWrapper wrapper) {
contentWrapper = wrapper;
}
if (body instanceof ApiResponseDto<?> apiResponse) {
response.setStatusCode(apiResponse.getHttpStatus());
String ip = ApiLogFunction.getClientIp(servletRequest);
Long userid = null;
if (servletRequest.getUserPrincipal() instanceof UsernamePasswordAuthenticationToken auth
&& auth.getPrincipal() instanceof CustomUserDetails customUserDetails) {
userid = customUserDetails.getMember().getId();
}
String requestBody;
// 멀티파트 요청은 바디 로깅을 생략 (파일 바이너리로 인한 문제 예방)
MediaType reqContentType = null;
try {
String ct = servletRequest.getContentType();
reqContentType = ct != null ? MediaType.valueOf(ct) : null;
} catch (Exception ignored) {
}
if (reqContentType != null && MediaType.MULTIPART_FORM_DATA.includes(reqContentType)) {
requestBody = "(multipart omitted)";
} else {
requestBody = ApiLogFunction.getRequestBody(servletRequest, contentWrapper);
requestBody = maskSensitiveFields(requestBody);
}
AuditLogEntity log =
new AuditLogEntity(
userid,
ApiLogFunction.getEventType(servletRequest),
ApiLogFunction.isSuccessFail(apiResponse),
ApiLogFunction.getUriMenuInfo(
menuService.getFindAll(), servletRequest.getRequestURI()),
ip,
servletRequest.getRequestURI(),
requestBody,
apiResponse.getErrorLogUid());
auditLogRepository.save(log);
}
return body;
}
/**
* 마스킹
*
* @param json
* @return
*/
private String maskSensitiveFields(String json) {
if (json == null) {
return null;
}
// password 마스킹
json = json.replaceAll("\"password\"\\s*:\\s*\"[^\"]*\"", "\"password\":\"****\"");
// 토큰 마스킹
json = json.replaceAll("\"accessToken\"\\s*:\\s*\"[^\"]*\"", "\"accessToken\":\"****\"");
json = json.replaceAll("\"refreshToken\"\\s*:\\s*\"[^\"]*\"", "\"refreshToken\":\"****\"");
return json;
}
}

View File

@@ -0,0 +1,199 @@
package com.kamco.cd.training.config.api;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.springframework.http.HttpStatus;
@Getter
@ToString
public class ApiResponseDto<T> {
private T data;
@JsonInclude(JsonInclude.Include.NON_NULL)
private Error error;
@JsonInclude(JsonInclude.Include.NON_NULL)
private T errorData;
@JsonIgnore private HttpStatus httpStatus;
@JsonIgnore private Long errorLogUid;
public ApiResponseDto(T data) {
this.data = data;
}
private ApiResponseDto(T data, HttpStatus httpStatus) {
this.data = data;
this.httpStatus = httpStatus;
}
public ApiResponseDto(ApiResponseCode code) {
this.error = new Error(code.getId(), code.getMessage());
}
public ApiResponseDto(ApiResponseCode code, String message) {
this.error = new Error(code.getId(), message);
}
public ApiResponseDto(ApiResponseCode code, String message, HttpStatus httpStatus) {
this.error = new Error(code.getId(), message);
this.httpStatus = httpStatus;
}
public ApiResponseDto(
ApiResponseCode code, String message, HttpStatus httpStatus, Long errorLogUid) {
this.error = new Error(code.getId(), message);
this.httpStatus = httpStatus;
this.errorLogUid = errorLogUid;
}
public ApiResponseDto(ApiResponseCode code, String message, T errorData) {
this.error = new Error(code.getId(), message);
this.errorData = errorData;
}
// HTTP 상태 코드가 내장된 ApiResponseDto 반환 메서드들
public static <T> ApiResponseDto<T> createOK(T data) {
return new ApiResponseDto<>(data, HttpStatus.CREATED);
}
public static <T> ApiResponseDto<T> ok(T data) {
return new ApiResponseDto<>(data, HttpStatus.OK);
}
public static <T> ApiResponseDto<ResponseObj> okObject(ResponseObj data) {
if (data.getCode().equals(ApiResponseCode.OK)) {
return new ApiResponseDto<>(data, HttpStatus.NO_CONTENT);
} else {
return new ApiResponseDto<>(data.getCode(), data.getMessage(), HttpStatus.CONFLICT);
}
}
public static <T> ApiResponseDto<T> deleteOk(T data) {
return new ApiResponseDto<>(data, HttpStatus.NO_CONTENT);
}
public static ApiResponseDto<String> createException(ApiResponseCode code) {
return new ApiResponseDto<>(code);
}
public static ApiResponseDto<String> createException(ApiResponseCode code, String message) {
return new ApiResponseDto<>(code, message);
}
public static ApiResponseDto<String> createException(
ApiResponseCode code, String message, HttpStatus httpStatus) {
return new ApiResponseDto<>(code, message, httpStatus);
}
public static ApiResponseDto<String> createException(
ApiResponseCode code, String message, HttpStatus httpStatus, Long errorLogUid) {
return new ApiResponseDto<>(code, message, httpStatus, errorLogUid);
}
public static <T> ApiResponseDto<T> createException(
ApiResponseCode code, String message, T data) {
return new ApiResponseDto<>(code, message, data);
}
@Getter
public static class Error {
private final String code;
private final String message;
public Error(String code, String message) {
this.code = code;
this.message = message;
}
}
/** Error가 아닌 Business상 성공이거나 실패인 경우, 메세지 함께 전달하기 위한 object */
@Getter
public static class ResponseObj {
private final ApiResponseCode code;
private final String message;
public ResponseObj(ApiResponseCode code, String message) {
this.code = code;
this.message = message;
}
}
@Getter
@RequiredArgsConstructor
public enum ApiResponseCode implements EnumType {
// @formatter:off
OK("요청이 성공하였습니다."),
BAD_REQUEST("요청 파라미터가 잘못되었습니다."),
BAD_GATEWAY("네트워크 상태가 불안정합니다."),
ALREADY_EXIST_MALL("이미 등록된 쇼핑센터입니다."),
NOT_FOUND_MAP("지도를 찾을 수 없습니다."),
UNAUTHORIZED("권한이 없습니다."),
CONFLICT("이미 등록된 컨텐츠입니다."),
NOT_FOUND("Resource를 찾을 수 없습니다."),
NOT_FOUND_DATA("데이터를 찾을 수 없습니다."),
NOT_FOUND_WEATHER_DATA("날씨 데이터를 찾을 수 없습니다."),
FAIL_SEND_MESSAGE("메시지를 전송하지 못했습니다."),
TOO_MANY_CONNECTED_MACHINES("연결된 기기가 너무 많습니다."),
UNAUTHENTICATED("인증에 실패하였습니다."),
INVALID_TOKEN("잘못된 토큰입니다."),
EXPIRED_TOKEN("만료된 토큰입니다."),
INTERNAL_SERVER_ERROR("서버에 문제가 발생 하였습니다."),
FORBIDDEN("권한을 확인해주세요."),
INVALID_PASSWORD("잘못된 비밀번호 입니다."),
NOT_FOUND_CAR_IN("입차정보가 없습니다."),
WRONG_STATUS("잘못된 상태입니다."),
FAIL_VERIFICATION("인증에 실패하였습니다."),
INVALID_EMAIL("잘못된 형식의 이메일입니다."),
REQUIRED_EMAIL("이메일은 필수 항목입니다."),
WRONG_PASSWORD("잘못된 패스워드입니다."),
DUPLICATE_EMAIL("이미 가입된 이메일입니다."),
DUPLICATE_DATA("이미 등록되어 있습니다."),
DATA_INTEGRITY_ERROR("데이터 무결성이 위반되어 요청을 처리할수 없습니다."),
FOREIGN_KEY_ERROR("참조 중인 데이터가 있어 삭제할 수 없습니다."),
DUPLICATE_EMPLOYEEID("이미 가입된 사번입니다."),
NOT_FOUND_USER_FOR_EMAIL("이메일로 유저를 찾을 수 없습니다."),
NOT_FOUND_USER("사용자를 찾을 수 없습니다."),
UNPROCESSABLE_ENTITY("이 데이터는 삭제할 수 없습니다."),
LOGIN_ID_NOT_FOUND("아이디를 잘못 입력하셨습니다."),
LOGIN_PASSWORD_MISMATCH("비밀번호를 잘못 입력하셨습니다."),
LOGIN_PASSWORD_EXCEEDED("비밀번호 오류 횟수를 초과하여 이용하실 수 없습니다.\n로그인 오류에 대해 관리자에게 문의하시기 바랍니다."),
REFRESH_TOKEN_EXPIRED_OR_REVOKED("토큰 정보가 만료 되었습니다."),
REFRESH_TOKEN_MISMATCH("토큰 정보가 일치하지 않습니다."),
INACTIVE_ID("사용할 수 없는 계정입니다."),
INVALID_EMAIL_TOKEN(
"You can only reset your password within 24 hours from when the email was sent.\n"
+ "To reset your password again, please submit a new request through \"Forgot"
+ " Password.\""),
;
// @formatter:on
private final String message;
@Override
public String getId() {
return name();
}
@Override
public String getText() {
return message;
}
public static ApiResponseCode getCode(String name) {
return ApiResponseCode.valueOf(name.toUpperCase());
}
public static String getMessage(String name) {
return ApiResponseCode.valueOf(name.toUpperCase()).getText();
}
}
}

View File

@@ -0,0 +1,154 @@
package com.kamco.cd.training.dataset;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.dataset.dto.DatasetDto;
import com.kamco.cd.training.dataset.service.DatasetService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
@Tag(name = "데이터셋 관리", description = "어드민 홈 > 학습데이터관리 > 전체데이터 API")
@RestController
@RequestMapping("/api/datasets")
@RequiredArgsConstructor
public class DatasetApiController {
private final DatasetService datasetService;
@Operation(summary = "데이터셋 목록 조회", description = "데이터셋(회차) 목록을 조회합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Page.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping
public ApiResponseDto<Page<DatasetDto.Basic>> searchDatasets(
@Parameter(description = "구분", example = "DELIVER(납품), PRODUCTION(제작)")
@RequestParam(required = false)
String groupTitle,
@Parameter(description = "제목", example = "") @RequestParam(required = false) String title,
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0")
int page,
@Parameter(description = "페이지 크기", example = "20") @RequestParam(defaultValue = "20")
int size) {
DatasetDto.SearchReq searchReq = new DatasetDto.SearchReq();
searchReq.setTitle(title);
searchReq.setGroupTitle(groupTitle);
searchReq.setPage(page);
searchReq.setSize(size);
return ApiResponseDto.ok(datasetService.searchDatasets(searchReq));
}
@Operation(summary = "데이터셋 상세 조회", description = "데이터셋 상세 정보를 조회합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = DatasetDto.Basic.class))),
@ApiResponse(responseCode = "404", description = "데이터셋을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/{uuid}")
public ApiResponseDto<DatasetDto.Basic> getDatasetDetail(@PathVariable UUID uuid) {
return ApiResponseDto.ok(datasetService.getDatasetDetail(uuid));
}
@Operation(summary = "데이터셋 등록", description = "신규 데이터셋(회차)을 생성합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "201",
description = "등록 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/register")
public ApiResponseDto<Long> registerDataset(
@RequestBody @Valid DatasetDto.RegisterReq registerReq) {
Long id = datasetService.registerDataset(registerReq);
return ApiResponseDto.createOK(id);
}
@Operation(summary = "데이터셋 수정", description = "데이터셋 정보를 수정합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "수정 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content),
@ApiResponse(responseCode = "404", description = "데이터셋을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PutMapping("/{uuid}")
public ApiResponseDto<UUID> updateDataset(
@PathVariable UUID uuid, @RequestBody DatasetDto.UpdateReq updateReq) {
datasetService.updateDataset(uuid, updateReq);
return ApiResponseDto.ok(uuid);
}
@Operation(summary = "데이터셋 삭제", description = "데이터셋을 삭제합니다.")
@ApiResponses(
value = {
@ApiResponse(responseCode = "201", description = "삭제 성공", content = @Content),
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
@ApiResponse(responseCode = "404", description = "데이터셋을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@DeleteMapping("/{uuid}")
public ApiResponseDto<UUID> deleteDatasets(@PathVariable UUID uuid) {
datasetService.deleteDatasets(uuid);
return ApiResponseDto.ok(uuid);
}
/*
@Operation(summary = "데이터셋 통계 요약", description = "선택 데이터셋의 통계를 요약합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = DatasetDto.Summary.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/summary")
public ApiResponseDto<DatasetDto.Summary> getDatasetSummary(
@RequestBody @Valid DatasetDto.SummaryReq summaryReq) {
return ApiResponseDto.ok(datasetService.getDatasetSummary(summaryReq));
}
*/
}

View File

@@ -0,0 +1,56 @@
package com.kamco.cd.training.dataset;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.dataset.dto.MapSheetDto;
import com.kamco.cd.training.dataset.service.MapSheetService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
@Tag(name = "도엽 관리", description = "도엽(MapSheet) 관리 API")
@RestController
@RequiredArgsConstructor
public class MapSheetApiController {
private final MapSheetService mapSheetService;
@Operation(summary = "도엽 목록 조회", description = "데이터셋의 도엽 목록을 조회합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Page.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/api/datasets/items/search")
public ApiResponseDto<Page<MapSheetDto.Basic>> searchMapSheets(
@RequestBody @Valid MapSheetDto.SearchReq searchReq) {
return ApiResponseDto.ok(mapSheetService.searchMapSheets(searchReq));
}
@Operation(summary = "도엽 삭제", description = "도엽을 삭제합니다 (다건 지원).")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "삭제 성공", content = @Content),
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
@ApiResponse(responseCode = "404", description = "도엽을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/api/datasets/items/delete")
public ApiResponseDto<Void> deleteMapSheets(@RequestBody @Valid MapSheetDto.DeleteReq deleteReq) {
mapSheetService.deleteMapSheets(deleteReq);
return ApiResponseDto.ok(null);
}
}

View File

@@ -0,0 +1,212 @@
package com.kamco.cd.training.dataset.dto;
import com.kamco.cd.training.common.enums.LearnDataRegister;
import com.kamco.cd.training.common.enums.LearnDataType;
import com.kamco.cd.training.common.utils.enums.Enums;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public class DatasetDto {
@Schema(name = "Dataset Basic", description = "데이터셋 기본 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class Basic {
private Long id;
private UUID uuid;
private String groupTitle;
private String groupTitleCd;
private String title;
private Long roundNo;
private String totalSize;
private String memo;
@JsonFormatDttm private ZonedDateTime createdDttm;
private String status;
private String statusCd;
private Boolean deleted;
public Basic(
Long id,
UUID uuid,
String groupTitle,
String title,
Long roundNo,
Long totalSize,
String memo,
ZonedDateTime createdDttm,
String status,
Boolean deleted) {
this.id = id;
this.uuid = uuid;
this.groupTitle = getGroupTitle(groupTitle);
this.groupTitleCd = groupTitle;
this.title = title;
this.roundNo = roundNo;
this.totalSize = getTotalSize(totalSize);
this.memo = memo;
this.createdDttm = createdDttm;
this.status = getStatus(status);
this.statusCd = status;
this.deleted = deleted;
}
public String getTotalSize(Long totalSize) {
if (totalSize == null) return "0G";
double giga = totalSize / (1024.0 * 1024 * 1024);
return String.format("%.2fG", giga);
}
public String getGroupTitle(String groupTitleCd) {
LearnDataType type = Enums.fromId(LearnDataType.class, groupTitleCd);
return type == null ? null : type.getText();
}
public String getStatus(String status) {
LearnDataRegister type = Enums.fromId(LearnDataRegister.class, status);
return type == null ? null : type.getText();
}
}
@Schema(name = "Dataset Detail", description = "데이터셋 상세 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class Detail {
private Long id;
private String groupTitle;
private String title;
private Long roundNo;
private String totalSize;
private String memo;
@JsonFormatDttm private ZonedDateTime createdDttm;
private String status;
private Boolean deleted;
}
@Schema(name = "DatasetSearchReq", description = "데이터셋 목록 조회 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SearchReq {
@Schema(description = "구분", example = "DELIVER(납품), PRODUCTION(제작)")
private String groupTitle;
@Schema(description = "제목 (부분 검색)", example = "1차")
private String title;
@Schema(description = "페이지 번호 (1부터 시작)", example = "1")
private int page = 1;
@Schema(description = "페이지 크기", example = "20")
private int size = 20;
public Pageable toPageable() {
// API에서는 1부터 시작하지만 내부적으로는 0부터 시작
int pageIndex = Math.max(0, page - 1);
return PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createdDttm"));
}
}
@Schema(name = "DatasetDetailReq", description = "데이터셋 상세 조회 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class DetailReq {
@NotNull(message = "데이터셋 ID는 필수입니다")
@Schema(description = "데이터셋 ID", example = "101")
private Long datasetId;
}
@Schema(name = "DatasetRegisterReq", description = "데이터셋 등록 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class RegisterReq {
@NotBlank(message = "제목은 필수입니다")
@Size(max = 200, message = "제목은 최대 200자까지 입력 가능합니다")
@Schema(description = "제목", example = "1차 제작")
private String title;
@NotBlank(message = "연도는 필수입니다")
@Size(max = 4, message = "연도는 4자리입니다")
@Schema(description = "연도 (YYYY)", example = "2024")
private String year;
@Schema(description = "회차", example = "1")
private Long roundNo;
@Schema(description = "메모", example = "데이터셋 설명")
private String memo;
}
@Schema(name = "DatasetUpdateReq", description = "데이터셋 수정 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class UpdateReq {
@Size(max = 200, message = "제목은 최대 200자까지 입력 가능합니다")
@Schema(description = "제목", example = "1차 제작")
private String title;
@Schema(description = "메모", example = "데이터셋 설명")
private String memo;
}
@Schema(name = "DatasetSummaryReq", description = "데이터셋 통계 요약 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SummaryReq {
@NotNull(message = "데이터셋 ID 목록은 필수입니다")
@Schema(description = "데이터셋 ID 목록", example = "[101, 105]")
private List<Long> datasetIds;
}
@Schema(name = "DatasetSummary", description = "데이터셋 통계 요약")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class Summary {
@Schema(description = "총 데이터셋 수", example = "2")
private int totalDatasets;
@Schema(description = "총 도엽 수", example = "1500")
private long totalMapSheets;
@Schema(description = "총 파일 크기 (bytes)", example = "10737418240")
private long totalFileSize;
@Schema(description = "평균 도엽 수", example = "750")
private double averageMapSheets;
}
}

View File

@@ -0,0 +1,103 @@
package com.kamco.cd.training.dataset.dto;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.time.ZonedDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public class MapSheetDto {
@Schema(name = "MapSheet Basic", description = "도엽 기본 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class Basic {
private Long id;
private Long datasetId;
private String sheetNum;
private String fileName;
private Long fileSize;
private String filePath;
private String status;
private String memo;
private Boolean deleted;
@JsonFormatDttm private ZonedDateTime createdDttm;
@JsonFormatDttm private ZonedDateTime updatedDttm;
}
@Schema(name = "MapSheetSearchReq", description = "도엽 목록 조회 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SearchReq {
@NotNull(message = "데이터셋 ID는 필수입니다")
@Schema(description = "데이터셋 ID", example = "101")
private Long datasetId;
@Schema(description = "페이지 번호 (1부터 시작)", example = "1")
private int page = 1;
@Schema(description = "페이지 크기", example = "20")
private int size = 20;
public Pageable toPageable() {
int pageIndex = Math.max(0, page - 1);
return PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createdDttm"));
}
}
@Schema(name = "MapSheetDeleteReq", description = "도엽 삭제 요청 (다건)")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class DeleteReq {
@NotNull(message = "삭제할 도엽 ID 목록은 필수입니다")
@Schema(description = "삭제할 도엽 ID 목록", example = "[9991, 9992]")
private List<Long> itemIds;
}
@Schema(name = "MapSheetCheckReq", description = "도엽 번호 유효성 검증 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class CheckReq {
@NotNull(message = "도엽 번호는 필수입니다")
@Schema(description = "도엽 번호", example = "377055")
private String sheetNum;
}
@Schema(name = "MapSheetCheckRes", description = "도엽 번호 유효성 검증 응답")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class CheckRes {
@Schema(description = "유효 여부", example = "true")
private boolean valid;
@Schema(description = "메시지", example = "유효한 도엽 번호입니다")
private String message;
@Schema(description = "중복 여부", example = "false")
private boolean duplicate;
}
}

View File

@@ -0,0 +1,86 @@
package com.kamco.cd.training.dataset.service;
import com.kamco.cd.training.dataset.dto.DatasetDto;
import com.kamco.cd.training.postgres.core.DatasetCoreService;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DatasetService {
private final DatasetCoreService datasetCoreService;
/**
* 데이터셋 목록 조회
*
* @param searchReq 검색 조건
* @return 데이터셋 목록
*/
public Page<DatasetDto.Basic> searchDatasets(DatasetDto.SearchReq searchReq) {
log.info("데이터셋 목록 조회 - 조건: {}", searchReq);
return datasetCoreService.findDatasetList(searchReq);
}
/**
* 데이터셋 상세 조회
*
* @param id 상세 조회할 목록 Id
* @return 데이터셋 상세 정보
*/
public DatasetDto.Basic getDatasetDetail(UUID id) {
return datasetCoreService.getOneByUuid(id);
}
/**
* 데이터셋 등록
*
* @param registerReq 등록 요청
* @return 등록된 데이터셋 ID
*/
@Transactional
public Long registerDataset(DatasetDto.RegisterReq registerReq) {
log.info("데이터셋 등록 - 요청: {}", registerReq);
DatasetDto.Basic saved = datasetCoreService.save(registerReq);
log.info("데이터셋 등록 완료 - ID: {}", saved.getId());
return saved.getId();
}
/**
* 데이터셋 수정
*
* @param updateReq 수정 요청
* @return 수정된 데이터셋 ID
*/
@Transactional
public void updateDataset(UUID uuid, DatasetDto.UpdateReq updateReq) {
datasetCoreService.update(uuid, updateReq);
}
/**
* 데이터셋 삭제 (다건)
*
* @param uuid 삭제 요청
*/
@Transactional
public void deleteDatasets(UUID uuid) {
datasetCoreService.deleteDatasets(uuid);
}
/**
* 데이터셋 통계 요약
*
* @param summaryReq 요약 요청
* @return 통계 요약
*/
public DatasetDto.Summary getDatasetSummary(DatasetDto.SummaryReq summaryReq) {
log.info("데이터셋 통계 요약 - 요청: {}", summaryReq);
return datasetCoreService.getDatasetSummary(summaryReq);
}
}

View File

@@ -0,0 +1,41 @@
package com.kamco.cd.training.dataset.service;
import com.kamco.cd.training.dataset.dto.MapSheetDto;
import com.kamco.cd.training.postgres.core.MapSheetCoreService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MapSheetService {
private final MapSheetCoreService mapSheetCoreService;
/**
* 도엽 목록 조회
*
* @param searchReq 검색 조건
* @return 도엽 목록
*/
public Page<MapSheetDto.Basic> searchMapSheets(MapSheetDto.SearchReq searchReq) {
log.info("도엽 목록 조회 - 조건: {}", searchReq);
return mapSheetCoreService.findMapSheetList(searchReq);
}
/**
* 도엽 삭제 (다건)
*
* @param deleteReq 삭제 요청
*/
@Transactional
public void deleteMapSheets(MapSheetDto.DeleteReq deleteReq) {
log.info("도엽 삭제 - 요청: {}", deleteReq);
mapSheetCoreService.deleteMapSheets(deleteReq);
log.info("도엽 삭제 완료 - 개수: {}", deleteReq.getItemIds().size());
}
}

View File

@@ -0,0 +1,229 @@
package com.kamco.cd.training.log.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.ZonedDateTime;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public class AuditLogDto {
@Schema(name = "AuditLogBasic", description = "감사로그 기본 정보")
@Getter
public static class Basic {
@JsonIgnore private final Long id;
private final Long userUid;
private final EventType eventType;
private final EventStatus eventStatus;
private final String menuUid;
private final String ipAddress;
private final String requestUri;
private final String requestBody;
private final Long errorLogUid;
@JsonFormatDttm private final ZonedDateTime createdDttm;
public Basic(
Long id,
Long userUid,
EventType eventType,
EventStatus eventStatus,
String menuUid,
String ipAddress,
String requestUri,
String requestBody,
Long errorLogUid,
ZonedDateTime createdDttm) {
this.id = id;
this.userUid = userUid;
this.eventType = eventType;
this.eventStatus = eventStatus;
this.menuUid = menuUid;
this.ipAddress = ipAddress;
this.requestUri = requestUri;
this.requestBody = requestBody;
this.errorLogUid = errorLogUid;
this.createdDttm = createdDttm;
}
}
@Schema(name = "AuditCommon", description = "목록 공통")
@Getter
@AllArgsConstructor
public static class AuditCommon {
private int readCount;
private int cudCount;
private int printCount;
private int downloadCount;
private Long totalCount;
}
@Schema(name = "DailyAuditList", description = "일자별 목록")
@Getter
public static class DailyAuditList extends AuditCommon {
private final String baseDate;
public DailyAuditList(
int readCount,
int cudCount,
int printCount,
int downloadCount,
Long totalCount,
String baseDate) {
super(readCount, cudCount, printCount, downloadCount, totalCount);
this.baseDate = baseDate;
}
}
@Schema(name = "MenuAuditList", description = "메뉴별 목록")
@Getter
public static class MenuAuditList extends AuditCommon {
private final String menuId;
private final String menuName;
public MenuAuditList(
String menuId,
String menuName,
int readCount,
int cudCount,
int printCount,
int downloadCount,
Long totalCount) {
super(readCount, cudCount, printCount, downloadCount, totalCount);
this.menuId = menuId;
this.menuName = menuName;
}
}
@Schema(name = "UserAuditList", description = "사용자별 목록")
@Getter
public static class UserAuditList extends AuditCommon {
private final Long accountId;
private final String loginId;
private final String username;
public UserAuditList(
Long accountId,
String loginId,
String username,
int readCount,
int cudCount,
int printCount,
int downloadCount,
Long totalCount) {
super(readCount, cudCount, printCount, downloadCount, totalCount);
this.accountId = accountId;
this.loginId = loginId;
this.username = username;
}
}
@Schema(name = "AuditDetail", description = "감사 로그 상세 공통")
@Getter
@AllArgsConstructor
public static class AuditDetail {
private Long logId;
private EventType eventType;
private LogDetail detail;
}
@Schema(name = "DailyDetail", description = "일자별 로그 상세")
@Getter
public static class DailyDetail extends AuditDetail {
private final String userName;
private final String loginId;
private final String menuName;
public DailyDetail(
Long logId,
String userName,
String loginId,
String menuName,
EventType eventType,
LogDetail detail) {
super(logId, eventType, detail);
this.userName = userName;
this.loginId = loginId;
this.menuName = menuName;
}
}
@Schema(name = "MenuDetail", description = "메뉴별 로그 상세")
@Getter
public static class MenuDetail extends AuditDetail {
private final String logDateTime;
private final String userName;
private final String loginId;
public MenuDetail(
Long logId,
String logDateTime,
String userName,
String loginId,
EventType eventType,
LogDetail detail) {
super(logId, eventType, detail);
this.logDateTime = logDateTime;
this.userName = userName;
this.loginId = loginId;
}
}
@Schema(name = "UserDetail", description = "사용자별 로그 상세")
@Getter
public static class UserDetail extends AuditDetail {
private final String logDateTime;
private final String menuNm;
public UserDetail(
Long logId, String logDateTime, String menuNm, EventType eventType, LogDetail detail) {
super(logId, eventType, detail);
this.logDateTime = logDateTime;
this.menuNm = menuNm;
}
}
@Getter
@Setter
@AllArgsConstructor
public static class LogDetail {
String serviceName;
String parentMenuName;
String menuName;
String menuUrl;
String menuDescription;
Long sortOrder;
boolean used;
}
@Schema(name = "searchReq", description = "일자별 로그 검색 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class searchReq {
// 페이징 파라미터
private int page = 0;
private int size = 20;
private String sort;
public Pageable toPageable() {
if (sort != null && !sort.isEmpty()) {
String[] sortParams = sort.split(",");
String property = sortParams[0];
Sort.Direction direction =
sortParams.length > 1 ? Sort.Direction.fromString(sortParams[1]) : Sort.Direction.ASC;
return PageRequest.of(page, size, Sort.by(direction, property));
}
return PageRequest.of(page, size);
}
}
}

View File

@@ -0,0 +1,101 @@
package com.kamco.cd.training.log.dto;
import com.kamco.cd.training.common.utils.enums.EnumType;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public class ErrorLogDto {
@Schema(name = "ErrorLogBasic", description = "에러로그 기본 정보")
@Getter
@Setter
@AllArgsConstructor
public static class Basic {
private final Long id;
private final String serviceName;
private final String menuNm;
private final String loginId;
private final String userName;
private final EventType errorType;
private final String errorName;
private final LogErrorLevel errorLevel;
private final String errorCode;
private final String errorMessage;
private final String errorDetail;
private final String createDate; // to_char해서 가져옴
}
@Schema(name = "ErrorSearchReq", description = "에러로그 검색 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class ErrorSearchReq {
LogErrorLevel errorLevel;
EventType eventType;
LocalDate startDate;
LocalDate endDate;
// 페이징 파라미터
private int page = 0;
private int size = 20;
private String sort;
public ErrorSearchReq(
LogErrorLevel errorLevel,
EventType eventType,
LocalDate startDate,
LocalDate endDate,
int page,
int size) {
this.errorLevel = errorLevel;
this.eventType = eventType;
this.startDate = startDate;
this.endDate = endDate;
this.page = page;
this.size = size;
}
public Pageable toPageable() {
if (sort != null && !sort.isEmpty()) {
String[] sortParams = sort.split(",");
String property = sortParams[0];
Sort.Direction direction =
sortParams.length > 1 ? Sort.Direction.fromString(sortParams[1]) : Sort.Direction.ASC;
return PageRequest.of(page, size, Sort.by(direction, property));
}
return PageRequest.of(page, size);
}
}
public enum LogErrorLevel implements EnumType {
WARNING("Warning"),
ERROR("Error"),
CRITICAL("Critical");
private final String desc;
LogErrorLevel(String desc) {
this.desc = desc;
}
@Override
public String getId() {
return name();
}
@Override
public String getText() {
return desc;
}
}
}

View File

@@ -0,0 +1,24 @@
package com.kamco.cd.training.log.dto;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum EventStatus implements EnumType {
SUCCESS("이벤트 결과 성공"),
FAILED("이벤트 결과 실패");
private final String desc;
@Override
public String getId() {
return name();
}
@Override
public String getText() {
return desc;
}
}

View File

@@ -0,0 +1,29 @@
package com.kamco.cd.training.log.dto;
import com.kamco.cd.training.common.utils.enums.EnumType;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum EventType implements EnumType {
CREATE("생성"),
READ("조회"),
UPDATE("수정"),
DELETE("삭제"),
DOWNLOAD("다운로드"),
PRINT("출력"),
OTHER("기타");
private final String desc;
@Override
public String getId() {
return name();
}
@Override
public String getText() {
return desc;
}
}

View File

@@ -0,0 +1,46 @@
package com.kamco.cd.training.log.service;
import com.kamco.cd.training.log.dto.AuditLogDto;
import com.kamco.cd.training.postgres.core.AuditLogCoreService;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuditLogService {
private final AuditLogCoreService auditLogCoreService;
public Page<AuditLogDto.DailyAuditList> getLogByDaily(
AuditLogDto.searchReq searchRange, LocalDate startDate, LocalDate endDate) {
return auditLogCoreService.getLogByDaily(searchRange, startDate, endDate);
}
public Page<AuditLogDto.MenuAuditList> getLogByMenu(
AuditLogDto.searchReq searchRange, String searchValue) {
return auditLogCoreService.getLogByMenu(searchRange, searchValue);
}
public Page<AuditLogDto.UserAuditList> getLogByAccount(
AuditLogDto.searchReq searchRange, String searchValue) {
return auditLogCoreService.getLogByAccount(searchRange, searchValue);
}
public Page<AuditLogDto.DailyDetail> getLogByDailyResult(
AuditLogDto.searchReq searchRange, LocalDate logDate) {
return auditLogCoreService.getLogByDailyResult(searchRange, logDate);
}
public Page<AuditLogDto.MenuDetail> getLogByMenuResult(
AuditLogDto.searchReq searchRange, String menuId) {
return auditLogCoreService.getLogByMenuResult(searchRange, menuId);
}
public Page<AuditLogDto.UserDetail> getLogByAccountResult(
AuditLogDto.searchReq searchRange, Long accountId) {
return auditLogCoreService.getLogByAccountResult(searchRange, accountId);
}
}

View File

@@ -0,0 +1,19 @@
package com.kamco.cd.training.log.service;
import com.kamco.cd.training.log.dto.ErrorLogDto;
import com.kamco.cd.training.postgres.core.ErrorLogCoreService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ErrorLogService {
private final ErrorLogCoreService errorLogCoreService;
public Page<ErrorLogDto.Basic> findLogByError(ErrorLogDto.ErrorSearchReq searchReq) {
return errorLogCoreService.findLogByError(searchReq);
}
}

View File

@@ -0,0 +1,241 @@
package com.kamco.cd.training.members;
import com.kamco.cd.training.auth.CustomUserDetails;
import com.kamco.cd.training.auth.JwtTokenProvider;
import com.kamco.cd.training.common.enums.StatusType;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.members.dto.MembersDto;
import com.kamco.cd.training.members.dto.SignInRequest;
import com.kamco.cd.training.members.dto.TokenResponse;
import com.kamco.cd.training.members.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import java.nio.file.AccessDeniedException;
import java.time.Duration;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "인증(Auth)", description = "로그인, 토큰 재발급, 로그아웃 API")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
@Value("${token.refresh-cookie-name}")
private String refreshCookieName;
@Value("${token.refresh-cookie-secure:true}")
private boolean refreshCookieSecure;
@PostMapping("/signin")
@Operation(summary = "로그인", description = "사번으로 로그인하여 액세스/리프레시 토큰을 발급.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "로그인 성공",
content = @Content(schema = @Schema(implementation = TokenResponse.class))),
@ApiResponse(
responseCode = "401",
description = "로그인 실패 (아이디/비밀번호 오류, 계정잠금 등)",
content =
@Content(
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(
name = "사번 입력 오류",
description = "존재하지 않는 아이디",
value =
"""
{
"code": "LOGIN_ID_NOT_FOUND",
"message": "사번을 잘못 입력하셨습니다."
}
"""),
@ExampleObject(
name = "비밀번호 입력 오류 (4회 이하)",
description = "아이디는 정상, 비밀번호를 여러 번 틀린 경우",
value =
"""
{
"code": "LOGIN_PASSWORD_MISMATCH",
"message": "비밀번호를 잘못 입력하셨습니다."
}
"""),
@ExampleObject(
name = "비밀번호 오류 횟수 초과",
description = "비밀번호 5회 이상 오류로 계정 잠김",
value =
"""
{
"code": "LOGIN_PASSWORD_EXCEEDED",
"message": "비밀번호 오류 횟수를 초과하여 이용하실 수 없습니다. 로그인 오류에 대해 관리자에게 문의하시기 바랍니다."
}
"""),
@ExampleObject(
name = "사용 중지 된 계정의 로그인 시도",
description = "사용 중지 된 계정의 로그인 시도",
value =
"""
{
"code": "INACTIVE_ID",
"message": "사용할 수 없는 계정입니다."
}
""")
}))
})
public ApiResponseDto<TokenResponse> signin(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "로그인 요청 정보",
required = true)
@RequestBody
SignInRequest request,
HttpServletResponse response) {
// 사용자 상태 조회
String status = authService.getUserStatus(request);
if(StatusType.INACTIVE.getId().equals(status)) {
throw new CustomApiException("INACTIVE_ID", HttpStatus.UNAUTHORIZED);
}
Authentication authentication = null;
MembersDto.Member member = new MembersDto.Member();
authentication =
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
String username = authentication.getName(); // UserDetailsService 에서 사용한 username
String accessToken = jwtTokenProvider.createAccessToken(username);
String refreshToken = jwtTokenProvider.createRefreshToken(username);
// 토큰 저장
authService.tokenSave(username, refreshToken, jwtTokenProvider.getRefreshTokenValidityInMs());
// HttpOnly + Secure 쿠키에 RefreshToken 저장
ResponseCookie cookie =
ResponseCookie.from(refreshCookieName, refreshToken)
.httpOnly(true)
.secure(refreshCookieSecure)
.path("/")
.maxAge(Duration.ofMillis(jwtTokenProvider.getRefreshTokenValidityInMs()))
.sameSite("Strict")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
member.setId(user.getMember().getId());
member.setName(user.getMember().getName());
member.setEmployeeNo(user.getMember().getEmployeeNo());
// PENDING 비활성 상태(새로운 패스워드 입력 해야함)
if (StatusType.PENDING.getId().equals(status)) {
member.setEmployeeNo(request.getUsername());
return ApiResponseDto.ok(new TokenResponse(status, accessToken, refreshToken, member));
}
// 인증 성공 로그인 시간 저장
authService.saveLogin(UUID.fromString(username));
return ApiResponseDto.ok(new TokenResponse(status, accessToken, refreshToken, member));
}
@PostMapping("/refresh")
@Operation(summary = "토큰 재발급", description = "리프레시 토큰으로 새로운 액세스/리프레시 토큰을 재발급합니다.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "재발급 성공",
content = @Content(schema = @Schema(implementation = TokenResponse.class))),
@ApiResponse(
responseCode = "403",
description = "만료되었거나 유효하지 않은 리프레시 토큰",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public ResponseEntity<TokenResponse> refresh(String refreshToken, HttpServletResponse response)
throws AccessDeniedException {
if (refreshToken == null || !jwtTokenProvider.isValidToken(refreshToken)) {
throw new AccessDeniedException("만료되었거나 유효하지 않은 리프레시 토큰 입니다.");
}
String username = jwtTokenProvider.getSubject(refreshToken);
// 저장된 RefreshToken과 일치하는지 확인
authService.validateRefreshToken(username, refreshToken);
// 새 토큰 발급
String newAccessToken = jwtTokenProvider.createAccessToken(username);
String newRefreshToken = jwtTokenProvider.createRefreshToken(username);
// 토큰 저장
authService.tokenSave(username, refreshToken, jwtTokenProvider.getRefreshTokenValidityInMs());
// 쿠키 갱신
ResponseCookie cookie =
ResponseCookie.from(refreshCookieName, newRefreshToken)
.httpOnly(true)
.secure(refreshCookieSecure)
.path("/")
.maxAge(Duration.ofMillis(jwtTokenProvider.getRefreshTokenValidityInMs()))
.sameSite("Strict")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
MembersDto.Member member = new MembersDto.Member();
return ResponseEntity.ok(new TokenResponse("ACTIVE", newAccessToken, newRefreshToken, member));
}
@PostMapping("/logout")
@Operation(summary = "로그아웃", description = "현재 사용자의 토큰을 무효화(리프레시 토큰 삭제)합니다.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "로그아웃 성공",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public ApiResponseDto<ResponseEntity<Object>> logout(
Authentication authentication, HttpServletResponse response) {
if (authentication != null) {
String username = authentication.getName();
authService.logout(username);
}
// 쿠키 삭제 (Max-Age=0)
ResponseCookie cookie =
ResponseCookie.from(refreshCookieName, "")
.httpOnly(true)
.secure(refreshCookieSecure)
.path("/")
.maxAge(0)
.sameSite("Strict")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ApiResponseDto.createOK(ResponseEntity.noContent().build());
}
}

View File

@@ -0,0 +1,69 @@
package com.kamco.cd.training.members;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.members.dto.MembersDto;
import com.kamco.cd.training.members.dto.MembersDto.Basic;
import com.kamco.cd.training.members.service.MembersService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.web.bind.annotation.*;
@Tag(name = "회원정보 관리", description = "회원정보 관리 API")
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MembersApiController {
private final AuthenticationManager authenticationManager;
private final MembersService membersService;
@Operation(summary = "회원정보 목록", description = "회원정보 조회")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "검색 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Page.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/search")
public ApiResponseDto<Page<Basic>> getMemberList(
@RequestBody @Valid MembersDto.SearchReq searchReq) {
return ApiResponseDto.ok(membersService.findByMembers(searchReq));
}
@Operation(
summary = "사용자 비밀번호 변경",
description = "로그인 성공후 status가 INACTIVE일때 로그인 id를 memberId로 path 생성필요")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "201",
description = "사용자 비밀번호 변경",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content),
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PatchMapping("/{memberId}/password")
public ApiResponseDto<String> resetPassword(
@PathVariable String memberId, @RequestBody @Valid MembersDto.InitReq initReq) {
membersService.resetPassword(memberId, initReq);
return ApiResponseDto.createOK(memberId);
}
}

View File

@@ -0,0 +1,183 @@
package com.kamco.cd.training.members.dto;
import com.kamco.cd.training.common.enums.RoleType;
import com.kamco.cd.training.common.enums.StatusType;
import com.kamco.cd.training.common.utils.enums.Enums;
import com.kamco.cd.training.common.utils.interfaces.EnumValid;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.ZonedDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
public class MembersDto {
@Getter
@Setter
public static class Basic {
private Long id;
private UUID uuid;
private String userRole;
private String userRoleName;
private String name;
private String employeeNo;
private String status;
private String statusName;
@JsonFormatDttm private ZonedDateTime createdDttm;
@JsonFormatDttm private ZonedDateTime firstLoginDttm;
@JsonFormatDttm private ZonedDateTime lastLoginDttm;
@JsonFormatDttm private ZonedDateTime statusChgDttm;
public Basic(
Long id,
UUID uuid,
String userRole,
String name,
String employeeNo,
String status,
ZonedDateTime createdDttm,
ZonedDateTime firstLoginDttm,
ZonedDateTime lastLoginDttm,
ZonedDateTime statusChgDttm,
Boolean pwdResetYn) {
this.id = id;
this.uuid = uuid;
this.userRole = userRole;
this.userRoleName = getUserRoleName(userRole);
this.name = name;
this.employeeNo = employeeNo;
this.status = status;
this.statusName = getStatusName(status, pwdResetYn);
this.createdDttm = createdDttm;
this.firstLoginDttm = firstLoginDttm;
this.lastLoginDttm = lastLoginDttm;
this.statusChgDttm = statusChgDttm;
}
private String getUserRoleName(String roleId) {
RoleType type = Enums.fromId(RoleType.class, roleId);
return type.getText();
}
private String getStatusName(String status, Boolean pwdResetYn) {
StatusType type = Enums.fromId(StatusType.class, status);
pwdResetYn = pwdResetYn != null && pwdResetYn;
if (type.equals(StatusType.PENDING) && pwdResetYn) {
type = StatusType.ACTIVE;
}
return type.getText();
}
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SearchReq {
@Schema(description = "전체, 관리자(ADMIN), 라벨러(LABELER), 검수자(REVIEWER)", example = "")
private String userRole;
@Schema(description = "키워드", example = "홍길동")
private String keyword;
// 페이징 파라미터
@Schema(description = "페이지 번호 (0부터 시작) ", example = "0")
private int page = 0;
@Schema(description = "페이지 크기", example = "20")
private int size = 20;
public Pageable toPageable() {
return PageRequest.of(page, size);
}
}
@Getter
@Setter
public static class AddReq {
@Schema(description = "관리자 유형", example = "ADMIN")
@NotBlank
@EnumValid(enumClass = RoleType.class, message = "userRole은 ADMIN, LABELER, REVIEWER 만 가능합니다.")
private String userRole;
@Schema(description = "사번", example = "K20251212001")
@Size(max = 50)
private String employeeNo;
@Schema(description = "이름", example = "홍길동")
@NotBlank
@Size(min = 2, max = 100)
private String name;
@NotBlank
@Schema(description = "패스워드", example = "")
@Size(max = 255)
private String password;
public AddReq(String userRole, String employeeNo, String name, String password) {
this.userRole = userRole;
this.employeeNo = employeeNo;
this.name = name;
this.password = password;
}
}
@Getter
@Setter
public static class UpdateReq {
@Schema(description = "이름", example = "홍길동")
@Size(min = 2, max = 100)
private String name;
@Schema(description = "상태", example = "ACTIVE")
@EnumValid(enumClass = StatusType.class, message = "status는 ACTIVE, INACTIVE, DELETED 만 가능합니다.")
private String status;
@Schema(description = "패스워드", example = "")
@Size(max = 255)
private String password;
public UpdateReq(String name, String status, String password) {
this.name = name;
this.status = status;
this.password = password;
}
}
@Getter
@Setter
public static class InitReq {
@Schema(description = "기존 패스워드", example = "")
@Size(max = 255)
@NotBlank
private String oldPassword;
@Schema(description = "신규 패스워드", example = "")
@Size(max = 255)
@NotBlank
private String newPassword;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public static class Member {
private Long id;
private String name;
private String employeeNo;
}
}

View File

@@ -0,0 +1,20 @@
package com.kamco.cd.training.members.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString(exclude = "password")
public class SignInRequest {
@Schema(description = "사용자 ID", example = "1234567")
private String username;
@Schema(description = "비밀번호", example = "Admin2!@#")
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
}

View File

@@ -0,0 +1,16 @@
package com.kamco.cd.training.members.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
public class TokenResponse {
private String status;
private String accessToken;
private String refreshToken;
private MembersDto.Member member;
}

View File

@@ -0,0 +1,50 @@
package com.kamco.cd.training.members.exception;
import lombok.Getter;
@Getter
public class MemberException {
// *** Duplicate Member Exception ***
@Getter
public static class DuplicateMemberException extends RuntimeException {
public enum Field {
USER_ID,
EMPLOYEE_NO,
DEFAULT
}
private final Field field;
private final String value;
public DuplicateMemberException(Field field, String value) {
super(field.name() + " duplicate: " + value);
this.field = field;
this.value = value;
}
}
// *** Member Not Found Exception ***
public static class MemberNotFoundException extends RuntimeException {
public MemberNotFoundException() {
super("Member not found");
}
public MemberNotFoundException(String message) {
super(message);
}
}
public static class PasswordNotFoundException extends RuntimeException {
public PasswordNotFoundException() {
super("Password not found");
}
public PasswordNotFoundException(String message) {
super(message);
}
}
}

View File

@@ -0,0 +1,77 @@
package com.kamco.cd.training.members.service;
import com.kamco.cd.training.common.enums.error.AuthErrorCode;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.members.dto.SignInRequest;
import com.kamco.cd.training.postgres.core.MembersCoreService;
import com.kamco.cd.training.postgres.core.TokenCoreService;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {
private final MembersCoreService membersCoreService;
private final TokenCoreService tokenCoreService;
/**
* 토큰 저장
*
* @param subject
* @param refreshToken
* @param validityMs
*/
@Transactional
public void tokenSave(String subject, String refreshToken, long validityMs) {
tokenCoreService.save(subject, refreshToken, validityMs);
}
/**
* refreshToken을 DB와 비교 검증
*
* @param subject 사용자 식별(UUID)
* @param requestRefreshToken refresh token
*/
public void validateRefreshToken(String subject, String requestRefreshToken) {
String savedToken = tokenCoreService.getValidTokenOrThrow(subject);
if (!savedToken.equals(requestRefreshToken)) {
throw new CustomApiException(AuthErrorCode.REFRESH_TOKEN_MISMATCH);
}
}
/**
* 로그아웃(토큰폐기)
*
* @param subject 사용자 식별(UUID)
*/
@Transactional
public void logout(String subject) {
// RefreshToken 폐기
tokenCoreService.revokeBySubject(subject);
}
/**
* 로그인 일시 저장
*
* @param uuid
*/
@Transactional
public void saveLogin(UUID uuid) {
membersCoreService.saveLogin(uuid);
}
/**
* 사용자 상태 조회
*
* @param request
* @return
*/
public String getUserStatus(SignInRequest request) {
return membersCoreService.getUserStatus(request);
}
}

View File

@@ -0,0 +1,29 @@
package com.kamco.cd.training.members.service;
import com.kamco.cd.training.auth.CustomUserDetails;
import com.kamco.cd.training.postgres.entity.MemberEntity;
import com.kamco.cd.training.postgres.repository.members.MembersRepository;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class MemberDetailsService implements UserDetailsService {
private final MembersRepository membersRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UUID uuid = UUID.fromString(username);
MemberEntity member =
membersRepository
.findByUUID(uuid)
.orElseThrow(() -> new UsernameNotFoundException("USER NOT FOUND"));
return new CustomUserDetails(member);
}
}

View File

@@ -0,0 +1,45 @@
package com.kamco.cd.training.members.service;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.common.utils.CommonStringUtils;
import com.kamco.cd.training.members.dto.MembersDto;
import com.kamco.cd.training.members.dto.MembersDto.Basic;
import com.kamco.cd.training.postgres.core.MembersCoreService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MembersService {
private final MembersCoreService membersCoreService;
/**
* 회원목록 조회
*
* @param searchReq
* @return
*/
public Page<Basic> findByMembers(MembersDto.SearchReq searchReq) {
return membersCoreService.findByMembers(searchReq);
}
/**
* 패스워드 사용자 변경
*
* @param id
* @param initReq
*/
@Transactional
public void resetPassword(String id, MembersDto.InitReq initReq) {
if (!CommonStringUtils.isValidPassword(initReq.getNewPassword())) {
throw new CustomApiException("WRONG_PASSWORD", HttpStatus.BAD_REQUEST);
}
membersCoreService.resetPassword(id, initReq);
}
}

View File

@@ -0,0 +1,62 @@
package com.kamco.cd.training.menu;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.menu.dto.MenuDto;
import com.kamco.cd.training.menu.service.MenuService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "메뉴 관리", description = "메뉴 관리 API")
@RestController
@RequestMapping("/api/menu")
@RequiredArgsConstructor
public class MenuApiController {
private final MenuService menuService;
@Operation(summary = "메뉴 목록", description = "메뉴 목록 조회")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "검색 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Page.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping
public ApiResponseDto<List<MenuDto.Basic>> getFindAll() {
return ApiResponseDto.ok(menuService.getFindAll());
}
@Operation(summary = "캐시 초기화", description = "메뉴관리 캐시를 초기화합니다.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "캐시 초기화 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/cache/refresh")
public ApiResponseDto<String> refreshCommonCodeCache() {
menuService.refresh();
return ApiResponseDto.ok("메뉴관리 캐시가 초기화되었습니다.");
}
}

View File

@@ -0,0 +1,64 @@
package com.kamco.cd.training.menu.dto;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.ZonedDateTime;
import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;
public class MenuDto {
@Schema(name = "Menu Basic", description = "메뉴 기본 정보")
@Getter
@NoArgsConstructor
public static class Basic {
private String menuUid;
private String menuNm;
private String menuUrl;
private String description;
private Long menuOrder;
private Boolean isUse;
private Boolean deleted;
private Long createdUid;
private Long updatedUid;
private List<Basic> children;
@JsonFormatDttm private ZonedDateTime createdDttm;
@JsonFormatDttm private ZonedDateTime updatedDttm;
private String menuApiUrl;
public Basic(
String menuUid,
String menuNm,
String menuUrl,
String description,
Long menuOrder,
Boolean isUse,
Boolean deleted,
Long createdUid,
Long updatedUid,
List<Basic> children,
ZonedDateTime createdDttm,
ZonedDateTime updatedDttm,
String menuApiUrl) {
this.menuUid = menuUid;
this.menuNm = menuNm;
this.menuUrl = menuUrl;
this.description = description;
this.menuOrder = menuOrder;
this.isUse = isUse;
this.deleted = deleted;
this.createdUid = createdUid;
this.updatedUid = updatedUid;
this.children = children;
this.createdDttm = createdDttm;
this.updatedDttm = updatedDttm;
this.menuApiUrl = menuApiUrl;
}
}
}

View File

@@ -0,0 +1,27 @@
package com.kamco.cd.training.menu.service;
import com.kamco.cd.training.menu.dto.MenuDto;
import com.kamco.cd.training.postgres.core.MenuCoreService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
// training 서버는 Redis 사용하지 않고 Spring Boot 메모리 캐시를 사용함
// => org.springframework.cache.annotation.Cacheable
@Service
@RequiredArgsConstructor
public class MenuService {
private final MenuCoreService menuCoreService;
@Cacheable("trainMenuFindAll")
public List<MenuDto.Basic> getFindAll() {
return menuCoreService.getFindAll();
}
/** 메모리 캐시 초기화 */
@CacheEvict(value = "trainMenuFindAll", allEntries = true)
public void refresh() {}
}

View File

@@ -0,0 +1,286 @@
package com.kamco.cd.training.model;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.model.dto.ModelMngDto;
import com.kamco.cd.training.model.dto.ModelMngDto.Basic;
import com.kamco.cd.training.model.service.ModelMngService;
import com.kamco.cd.training.model.service.ModelTrainService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@Tag(name = "모델관리", description = "모델관리 (학습 모델, 하이퍼파라미터, 메모)")
@RequestMapping("/api/models")
public class ModelMngApiController {
private final ModelMngService modelMngService;
private final ModelTrainService modelTrainService;
@Operation(summary = "학습 모델 목록 조회", description = "학습 모델 목록을 조회합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "검색 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Page.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping
public ApiResponseDto<Page<Basic>> findByModels(
@Parameter(description = "상태 코드") @RequestParam(required = false) String status,
@Parameter(description = "페이지 번호") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) {
ModelMngDto.SearchReq searchReq = new ModelMngDto.SearchReq(status, page, size);
return ApiResponseDto.ok(modelMngService.findByModels(searchReq));
}
@Operation(summary = "학습 모델 상세 조회", description = "학습 모델의 상세 정보를 UUID로 조회합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ModelMngDto.Detail.class))),
@ApiResponse(responseCode = "404", description = "모델을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/{uuid}")
public ApiResponseDto<ModelMngDto.Detail> getModelDetail(
@Parameter(description = "모델 UUID", example = "b7e99739-6736-45f9-a224-8161ecddf287")
@PathVariable
String uuid) {
return ApiResponseDto.ok(modelMngService.getModelDetailByUuid(uuid));
}
// ==================== 학습 모델학습관리 API (5종) ====================
@Operation(summary = "학습 모델 통합 조회", description = "학습 관리 화면에서 학습 이력 리스트와 현재 상태를 조회합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = List.class))),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/train")
public ApiResponseDto<List<ModelMngDto.TrainListRes>> getTrainModelList() {
return ApiResponseDto.ok(modelTrainService.getTrainModelList());
}
@Operation(summary = "학습 설정 통합 조회", description = "학습 실행 팝업 구성에 필요한 모든 데이터를 한 번에 반환합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ModelMngDto.FormConfigRes.class))),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/train/form-config")
public ApiResponseDto<ModelMngDto.FormConfigRes> getFormConfig() {
return ApiResponseDto.ok(modelTrainService.getFormConfig());
}
@Operation(summary = "하이퍼파라미터 등록", description = "Step 1 에서 파라미터를 수정하여 신규 버전으로 저장합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "등록 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/hyper-params")
public ApiResponseDto<String> createHyperParam(
@Valid @RequestBody ModelMngDto.HyperParamCreateReq createReq) {
String newVersion = modelTrainService.createHyperParam(createReq);
return ApiResponseDto.ok(newVersion);
}
@Operation(summary = "하이퍼파라미터 단건 조회", description = "특정 버전의 하이퍼파라미터 상세 정보를 조회합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ModelMngDto.HyperParamInfo.class))),
@ApiResponse(responseCode = "404", description = "하이퍼파라미터를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/hyper-params/{hyperVer}")
public ApiResponseDto<ModelMngDto.HyperParamInfo> getHyperParam(
@Parameter(description = "하이퍼파라미터 버전", example = "H1") @PathVariable String hyperVer) {
return ApiResponseDto.ok(modelTrainService.getHyperParam(hyperVer));
}
@Operation(summary = "하이퍼파라미터 삭제", description = "특정 버전의 하이퍼파라미터를 삭제합니다 (H1은 삭제 불가)")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "삭제 성공", content = @Content),
@ApiResponse(responseCode = "400", description = "H1은 삭제 불가", content = @Content),
@ApiResponse(responseCode = "404", description = "하이퍼파라미터를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@DeleteMapping("/hyper-params/{hyperVer}")
public ApiResponseDto<Void> deleteHyperParam(
@Parameter(description = "하이퍼파라미터 버전", example = "V3.99.251221.120518") @PathVariable
String hyperVer) {
modelTrainService.deleteHyperParam(hyperVer);
return ApiResponseDto.ok(null);
}
@Operation(summary = "학습 시작", description = "모든 설정(Step 1~3)을 마치고 최종적으로 학습 프로세스를 시작합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "학습 시작 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ModelMngDto.TrainStartRes.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/train")
public ApiResponseDto<ModelMngDto.TrainStartRes> startTraining(
@Valid @RequestBody ModelMngDto.TrainStartReq trainReq) {
return ApiResponseDto.ok(modelTrainService.startTraining(trainReq));
}
@Operation(summary = "학습 모델 삭제", description = "목록에서 특정 학습 모델을 삭제합니다")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "삭제 성공", content = @Content),
@ApiResponse(responseCode = "400", description = "진행 중인 모델은 삭제 불가", content = @Content),
@ApiResponse(responseCode = "404", description = "모델을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@DeleteMapping("/train/{uuid}")
public ApiResponseDto<Void> deleteTrainModel(
@Parameter(description = "모델 UUID") @PathVariable String uuid) {
modelTrainService.deleteTrainModel(uuid);
return ApiResponseDto.ok(null);
}
// ==================== Resume Training (학습 재시작) ====================
@Operation(summary = "학습 재시작 정보 조회", description = "중단된 학습의 재시작 가능 여부와 Checkpoint 정보를 조회합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ModelMngDto.ResumeInfo.class))),
@ApiResponse(responseCode = "404", description = "모델을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/train/{uuid}/resume-info")
public ApiResponseDto<ModelMngDto.ResumeInfo> getResumeInfo(
@Parameter(description = "모델 UUID") @PathVariable String uuid) {
return ApiResponseDto.ok(modelTrainService.getResumeInfo(uuid));
}
@Operation(summary = "학습 재시작", description = "중단된 지점(Checkpoint)부터 학습을 재개합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "재시작 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ModelMngDto.ResumeResponse.class))),
@ApiResponse(responseCode = "400", description = "재시작 불가능한 상태", content = @Content),
@ApiResponse(responseCode = "404", description = "모델을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/train/{uuid}/resume")
public ApiResponseDto<ModelMngDto.ResumeResponse> resumeTraining(
@Parameter(description = "모델 UUID") @PathVariable String uuid,
@Valid @RequestBody ModelMngDto.ResumeRequest resumeReq) {
return ApiResponseDto.ok(modelTrainService.resumeTraining(uuid, resumeReq));
}
// ==================== Best Epoch Setting (Best Epoch 설정) ====================
@Operation(summary = "Best Epoch 설정", description = "사용자가 직접 Best Epoch를 선택하여 설정합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "설정 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ModelMngDto.BestEpochResponse.class))),
@ApiResponse(responseCode = "404", description = "모델을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/train/{uuid}/best-epoch")
public ApiResponseDto<ModelMngDto.BestEpochResponse> setBestEpoch(
@Parameter(description = "모델 UUID") @PathVariable String uuid,
@Valid @RequestBody ModelMngDto.BestEpochRequest bestEpochReq) {
return ApiResponseDto.ok(modelTrainService.setBestEpoch(uuid, bestEpochReq));
}
@Operation(summary = "Epoch별 성능 지표 조회", description = "학습된 모델의 Epoch별 성능 지표를 조회합니다")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = List.class))),
@ApiResponse(responseCode = "404", description = "모델을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/train/{uuid}/epoch-metrics")
public ApiResponseDto<List<ModelMngDto.EpochMetric>> getEpochMetrics(
@Parameter(description = "모델 UUID") @PathVariable String uuid) {
return ApiResponseDto.ok(modelTrainService.getEpochMetrics(uuid));
}
}

View File

@@ -0,0 +1,219 @@
package com.kamco.cd.training.model.dto;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import com.kamco.cd.training.postgres.entity.ModelHyperParamEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import java.time.ZonedDateTime;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
public class HyperParamDto {
@Schema(name = "HyperParam Basic", description = "하이퍼파라미터 기본 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class Basic {
private String hyperVer;
// Important
private String backbone;
private String inputSize;
private String cropSize;
private Integer epochCnt;
private Integer batchSize;
// Architecture
private Double dropPathRate;
private Integer frozenStages;
private String neckPolicy;
private String decoderChannels;
private String classWeight;
private Integer numLayers;
// Optimization
private Double learningRate;
private Double weightDecay;
private Double layerDecayRate;
private Boolean ddpFindUnusedParams;
private Integer ignoreIndex;
// Data
private Integer trainNumWorkers;
private Integer valNumWorkers;
private Integer testNumWorkers;
private Boolean trainShuffle;
private Boolean trainPersistent;
private Boolean valPersistent;
// Evaluation
private String metrics;
private String saveBest;
private String saveBestRule;
private Integer valInterval;
private Integer logInterval;
private Integer visInterval;
// Hardware
private Integer gpuCnt;
private String gpuIds;
private Integer masterPort;
// Augmentation
private Double rotProb;
private Double flipProb;
private String rotDegree;
private Double exchangeProb;
private Integer brightnessDelta;
private String contrastRange;
private String saturationRange;
private Integer hueDelta;
// Legacy (deprecated)
private Double dropoutRatio;
private Integer cnnFilterCnt;
// Common
private String memo;
@JsonFormatDttm private ZonedDateTime createdDttm;
public Basic(ModelHyperParamEntity entity) {
this.hyperVer = entity.getHyperVer();
// Important
this.backbone = entity.getBackbone();
this.inputSize = entity.getInputSize();
this.cropSize = entity.getCropSize();
this.epochCnt = entity.getEpochCnt();
this.batchSize = entity.getBatchSize();
// Architecture
this.dropPathRate = entity.getDropPathRate();
this.frozenStages = entity.getFrozenStages();
this.neckPolicy = entity.getNeckPolicy();
this.decoderChannels = entity.getDecoderChannels();
this.classWeight = entity.getClassWeight();
this.numLayers = entity.getNumLayers();
// Optimization
this.learningRate = entity.getLearningRate();
this.weightDecay = entity.getWeightDecay();
this.layerDecayRate = entity.getLayerDecayRate();
this.ddpFindUnusedParams = entity.getDdpFindUnusedParams();
this.ignoreIndex = entity.getIgnoreIndex();
// Data
this.trainNumWorkers = entity.getTrainNumWorkers();
this.valNumWorkers = entity.getValNumWorkers();
this.testNumWorkers = entity.getTestNumWorkers();
this.trainShuffle = entity.getTrainShuffle();
this.trainPersistent = entity.getTrainPersistent();
this.valPersistent = entity.getValPersistent();
// Evaluation
this.metrics = entity.getMetrics();
this.saveBest = entity.getSaveBest();
this.saveBestRule = entity.getSaveBestRule();
this.valInterval = entity.getValInterval();
this.logInterval = entity.getLogInterval();
this.visInterval = entity.getVisInterval();
// Hardware
this.gpuCnt = entity.getGpuCnt();
this.gpuIds = entity.getGpuIds();
this.masterPort = entity.getMasterPort();
// Augmentation
this.rotProb = entity.getRotProb();
this.flipProb = entity.getFlipProb();
this.rotDegree = entity.getRotDegree();
this.exchangeProb = entity.getExchangeProb();
this.brightnessDelta = entity.getBrightnessDelta();
this.contrastRange = entity.getContrastRange();
this.saturationRange = entity.getSaturationRange();
this.hueDelta = entity.getHueDelta();
// Legacy
this.dropoutRatio = entity.getDropoutRatio();
this.cnnFilterCnt = entity.getCnnFilterCnt();
// Common
this.memo = entity.getMemo();
this.createdDttm = entity.getCreatedDttm();
}
}
@Schema(name = "HyperParam AddReq", description = "하이퍼파라미터 등록 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class AddReq {
@NotBlank(message = "버전명은 필수입니다")
private String hyperVer;
// Important
private String backbone;
private String inputSize;
private String cropSize;
private Integer epochCnt;
private Integer batchSize;
// Architecture
private Double dropPathRate;
private Integer frozenStages;
private String neckPolicy;
private String decoderChannels;
private String classWeight;
private Integer numLayers;
// Optimization
private Double learningRate;
private Double weightDecay;
private Double layerDecayRate;
private Boolean ddpFindUnusedParams;
private Integer ignoreIndex;
// Data
private Integer trainNumWorkers;
private Integer valNumWorkers;
private Integer testNumWorkers;
private Boolean trainShuffle;
private Boolean trainPersistent;
private Boolean valPersistent;
// Evaluation
private String metrics;
private String saveBest;
private String saveBestRule;
private Integer valInterval;
private Integer logInterval;
private Integer visInterval;
// Hardware
private Integer gpuCnt;
private String gpuIds;
private Integer masterPort;
// Augmentation
private Double rotProb;
private Double flipProb;
private String rotDegree;
private Double exchangeProb;
private Integer brightnessDelta;
private String contrastRange;
private String saturationRange;
private Integer hueDelta;
// Legacy (deprecated)
private Double dropoutRatio;
private Integer cnnFilterCnt;
// Common
private String memo;
}
}

View File

@@ -0,0 +1,595 @@
package com.kamco.cd.training.model.dto;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
public class ModelMngDto {
@Schema(name = "모델관리 목록 조회", description = "모델관리 목록 조회")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class Basic {
private Long id;
private String modelNm;
@JsonFormatDttm private ZonedDateTime startDttm;
@JsonFormatDttm private ZonedDateTime trainingEndDttm;
@JsonFormatDttm private ZonedDateTime testEndDttm;
private String durationDttm;
private String processStage;
private String statusCd;
private String status;
}
@Schema(name = "searchReq", description = "모델 관리 목록조회 파라미터")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SearchReq {
private String status;
// 페이징 파라미터
private int page = 0;
private int size = 20;
public Pageable toPageable() {
return PageRequest.of(page, size);
}
}
@Schema(name = "Detail", description = "모델 상세 정보")
@Getter
@Builder
public static class Detail {
private String uuid;
private String modelVer;
private String hyperVer;
private String epochVer;
private String processStep;
private String statusCd;
private String statusText;
@JsonFormatDttm private ZonedDateTime trainStartDttm;
private Integer epochCnt;
private String datasetRatio;
private Integer bestEpoch;
private Integer confirmedBestEpoch;
@JsonFormatDttm private ZonedDateTime step1EndDttm;
private String step1Duration;
@JsonFormatDttm private ZonedDateTime step2EndDttm;
private String step2Duration;
private Integer progressRate;
@JsonFormatDttm private ZonedDateTime createdDttm;
@JsonFormatDttm private ZonedDateTime updatedDttm;
private String modelPath;
private String errorMsg;
}
@Schema(name = "TrainListRes", description = "학습 모델 목록 응답")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class TrainListRes {
private String uuid;
private String modelVer;
private String status;
private String processStep;
@JsonFormatDttm private ZonedDateTime trainStartDttm;
private Integer progressRate;
private Integer epochCnt;
@JsonFormatDttm private ZonedDateTime step1EndDttm;
private String step1Duration;
@JsonFormatDttm private ZonedDateTime step2EndDttm;
private String step2Duration;
@JsonFormatDttm private ZonedDateTime createdDttm;
private String errorMsg;
private Boolean canResume;
private Integer lastCheckpointEpoch;
}
@Schema(name = "FormConfigRes", description = "학습 설정 통합 조회 응답")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class FormConfigRes {
private Boolean isTrainAvailable;
private List<HyperParamInfo> hyperParams;
private List<DatasetInfo> datasets;
private String runningModelUuid;
}
@Schema(name = "HyperParamInfo", description = "하이퍼파라미터 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class HyperParamInfo {
@Schema(description = "하이퍼파라미터 버전", example = "V3.99.251221.120518")
private String hyperVer;
// Important
@Schema(description = "백본", example = "large")
private String backbone;
@Schema(description = "입력 사이즈", example = "256,256")
private String inputSize;
@Schema(description = "크롭 사이즈", example = "256,256")
private String cropSize;
@Schema(description = "에폭 수", example = "200")
private Integer epochCnt;
@Schema(description = "배치 사이즈", example = "16")
private Integer batchSize;
// Architecture
@Schema(description = "Drop Path Rate", example = "0.3")
private Double dropPathRate;
@Schema(description = "Frozen Stages", example = "-1")
private Integer frozenStages;
@Schema(description = "Neck Policy", example = "abs_diff")
private String neckPolicy;
@Schema(description = "Decoder Channels", example = "512,256,128,64")
private String decoderChannels;
@Schema(description = "Class Weight", example = "1,1")
private String classWeight;
@Schema(description = "레이어 수", example = "24")
private Integer numLayers;
// Optimization
@Schema(description = "Learning Rate", example = "0.00006")
private Double learningRate;
@Schema(description = "Weight Decay", example = "0.05")
private Double weightDecay;
@Schema(description = "Layer Decay Rate", example = "0.9")
private Double layerDecayRate;
@Schema(description = "DDP Unused Params 찾기", example = "true")
private Boolean ddpFindUnusedParams;
@Schema(description = "Ignore Index", example = "255")
private Integer ignoreIndex;
// Data
@Schema(description = "Train Workers", example = "16")
private Integer trainNumWorkers;
@Schema(description = "Val Workers", example = "8")
private Integer valNumWorkers;
@Schema(description = "Test Workers", example = "8")
private Integer testNumWorkers;
@Schema(description = "Train Shuffle", example = "true")
private Boolean trainShuffle;
@Schema(description = "Train Persistent", example = "true")
private Boolean trainPersistent;
@Schema(description = "Val Persistent", example = "true")
private Boolean valPersistent;
// Evaluation
@Schema(description = "Metrics", example = "mFscore,mIoU")
private String metrics;
@Schema(description = "Save Best", example = "changed_fscore")
private String saveBest;
@Schema(description = "Save Best Rule", example = "greater")
private String saveBestRule;
@Schema(description = "Val Interval", example = "10")
private Integer valInterval;
@Schema(description = "Log Interval", example = "400")
private Integer logInterval;
@Schema(description = "Vis Interval", example = "1")
private Integer visInterval;
// Hardware
@Schema(description = "GPU 수", example = "4")
private Integer gpuCnt;
@Schema(description = "GPU IDs", example = "0,1,2,3")
private String gpuIds;
@Schema(description = "Master Port", example = "1122")
private Integer masterPort;
// Augmentation
@Schema(description = "Rotation 확률", example = "0.5")
private Double rotProb;
@Schema(description = "Flip 확률", example = "0.5")
private Double flipProb;
@Schema(description = "Rotation 각도", example = "-20,20")
private String rotDegree;
@Schema(description = "Exchange 확률", example = "0.5")
private Double exchangeProb;
@Schema(description = "Brightness Delta", example = "10")
private Integer brightnessDelta;
@Schema(description = "Contrast Range", example = "0.8,1.2")
private String contrastRange;
@Schema(description = "Saturation Range", example = "0.8,1.2")
private String saturationRange;
@Schema(description = "Hue Delta", example = "10")
private Integer hueDelta;
// Legacy
private Double dropoutRatio;
private Integer cnnFilterCnt;
// Common
@Schema(description = "메모", example = "안녕하세요 캠코담당자 입니다. 하이퍼파라미터 신규등록합니다")
private String memo;
@JsonFormatDttm private ZonedDateTime createdDttm;
}
@Schema(name = "DatasetInfo", description = "데이터셋 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class DatasetInfo {
private Long id;
private String title;
private String groupTitle;
private Long totalItems;
private String totalSize;
private Map<String, Integer> classCounts;
private String memo;
@JsonFormatDttm private ZonedDateTime createdDttm;
}
@Schema(name = "HyperParamCreateReq", description = "하이퍼파라미터 등록 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class HyperParamCreateReq {
// baseHyperVer는 필수 아님 (신규 생성 시 H1으로 자동 설정)
@Schema(description = "기준이 되는 하이퍼파라미터 버전", example = "H3")
private String baseHyperVer;
@NotBlank(message = "신규 버전명은 필수입니다")
@Schema(description = "새로 생성할 하이퍼파라미터 버전명", example = "V3.99.251221.120518")
private String newHyperVer;
// Important - 필수 필드
@NotBlank(message = "Backbone은 필수입니다")
@Schema(example = "large")
private String backbone;
@NotBlank(message = "Input Size는 필수입니다")
@Schema(example = "256,256")
private String inputSize;
@NotBlank(message = "Crop Size는 필수입니다")
@Schema(example = "256,256")
private String cropSize;
@NotNull(message = "Epoch Count는 필수입니다")
@Schema(example = "200")
private Integer epochCnt;
@NotNull(message = "Batch Size는 필수입니다")
@Schema(example = "16")
private Integer batchSize;
// Architecture - 필수 필드
@NotNull(message = "Drop Path Rate는 필수입니다")
@Schema(example = "0.3")
private Double dropPathRate;
@NotNull(message = "Frozen Stages는 필수입니다")
@Schema(example = "-1")
private Integer frozenStages;
@NotBlank(message = "Neck Policy는 필수입니다")
@Schema(example = "abs_diff")
private String neckPolicy;
@NotBlank(message = "Decoder Channels는 필수입니다")
@Schema(example = "512,256,128,64")
private String decoderChannels;
@NotBlank(message = "Class Weight는 필수입니다")
@Schema(example = "1,1")
private String classWeight;
// numLayers는 필수 아님
@Schema(example = "24")
private Integer numLayers;
// Optimization - 필수 필드
@NotNull(message = "Learning Rate는 필수입니다")
@Schema(example = "0.00006")
private Double learningRate;
@NotNull(message = "Weight Decay는 필수입니다")
@Schema(example = "0.05")
private Double weightDecay;
@NotNull(message = "Layer Decay Rate는 필수입니다")
@Schema(example = "0.9")
private Double layerDecayRate;
@NotNull(message = "DDP Find Unused Params는 필수입니다")
@Schema(example = "true")
private Boolean ddpFindUnusedParams;
@NotNull(message = "Ignore Index는 필수입니다")
@Schema(example = "255")
private Integer ignoreIndex;
// Data - 필수 필드
@NotNull(message = "Train Num Workers는 필수입니다")
@Schema(example = "16")
private Integer trainNumWorkers;
@NotNull(message = "Val Num Workers는 필수입니다")
@Schema(example = "8")
private Integer valNumWorkers;
@NotNull(message = "Test Num Workers는 필수입니다")
@Schema(example = "8")
private Integer testNumWorkers;
@NotNull(message = "Train Shuffle는 필수입니다")
@Schema(example = "true")
private Boolean trainShuffle;
@NotNull(message = "Train Persistent는 필수입니다")
@Schema(example = "true")
private Boolean trainPersistent;
@NotNull(message = "Val Persistent는 필수입니다")
@Schema(example = "true")
private Boolean valPersistent;
// Evaluation - 필수 필드
@NotBlank(message = "Metrics는 필수입니다")
@Schema(example = "mFscore,mIoU")
private String metrics;
@NotBlank(message = "Save Best는 필수입니다")
@Schema(example = "changed_fscore")
private String saveBest;
@NotBlank(message = "Save Best Rule은 필수입니다")
@Schema(example = "greater")
private String saveBestRule;
@NotNull(message = "Val Interval은 필수입니다")
@Schema(example = "10")
private Integer valInterval;
@NotNull(message = "Log Interval은 필수입니다")
@Schema(example = "400")
private Integer logInterval;
@NotNull(message = "Vis Interval은 필수입니다")
@Schema(example = "1")
private Integer visInterval;
// Hardware - 필수 아님 (예외 항목)
@Schema(example = "4")
private Integer gpuCnt;
@Schema(example = "0,1,2,3")
private String gpuIds;
@Schema(example = "1122")
private Integer masterPort;
// Augmentation - 필수 필드
@NotNull(message = "Rotation Probability는 필수입니다")
@Schema(example = "0.5")
private Double rotProb;
@NotNull(message = "Flip Probability는 필수입니다")
@Schema(example = "0.5")
private Double flipProb;
@NotBlank(message = "Rotation Degree는 필수입니다")
@Schema(example = "-20,20")
private String rotDegree;
@NotNull(message = "Exchange Probability는 필수입니다")
@Schema(example = "0.5")
private Double exchangeProb;
@NotNull(message = "Brightness Delta는 필수입니다")
@Schema(example = "10")
private Integer brightnessDelta;
@NotBlank(message = "Contrast Range는 필수입니다")
@Schema(example = "0.8,1.2")
private String contrastRange;
@NotBlank(message = "Saturation Range는 필수입니다")
@Schema(example = "0.8,1.2")
private String saturationRange;
@NotNull(message = "Hue Delta는 필수입니다")
@Schema(example = "10")
private Integer hueDelta;
// Legacy - 필수 아님 (예외 항목)
private Double dropoutRatio;
private Integer cnnFilterCnt;
// Common - 필수 아님 (예외 항목)
@Schema(example = "안녕하세요 캠코담당자 입니다. 하이퍼파라미터 신규등록합니다")
private String memo;
}
@Schema(name = "TrainStartReq", description = "학습 시작 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class TrainStartReq {
@NotBlank(message = "하이퍼파라미터 버전은 필수입니다")
@Schema(example = "V3.99.251221.120518")
private String hyperVer;
@NotEmpty(message = "데이터셋은 최소 1개 이상 선택해야 합니다")
private List<Long> datasetIds;
@NotNull(message = "에폭 수는 필수입니다")
@jakarta.validation.constraints.Min(value = 1, message = "에폭 수는 최소 1 이상이어야 합니다")
@jakarta.validation.constraints.Max(value = 200, message = "에폭 수는 최대 200까지 설정 가능합니다")
@Schema(example = "200")
private Integer epoch;
@Schema(example = "7:2:1", description = "데이터 분할 비율 (Training:Validation:Test)")
private String datasetRatio;
}
@Schema(name = "TrainStartRes", description = "학습 시작 응답")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class TrainStartRes {
private String uuid;
private String status;
}
@Schema(name = "ResumeInfo", description = "학습 재시작 정보")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ResumeInfo {
private Boolean canResume;
private Integer lastEpoch;
private Integer totalEpoch;
private String checkpointPath;
@JsonFormatDttm private ZonedDateTime failedAt;
}
@Schema(name = "ResumeRequest", description = "학습 재시작 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class ResumeRequest {
@NotNull(message = "재시작 Epoch는 필수입니다")
private Integer resumeFromEpoch;
private Integer newTotalEpoch;
}
@Schema(name = "ResumeResponse", description = "학습 재시작 응답")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ResumeResponse {
private String uuid;
private String status;
private Integer resumedFromEpoch;
}
@Schema(name = "BestEpochRequest", description = "Best Epoch 설정 요청")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class BestEpochRequest {
@NotNull(message = "Best Epoch는 필수입니다")
private Integer bestEpoch;
private String reason;
}
@Schema(name = "BestEpochResponse", description = "Best Epoch 설정 응답")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class BestEpochResponse {
private String uuid;
private Integer bestEpoch;
private Integer confirmedBestEpoch;
private Integer previousBestEpoch;
}
@Schema(name = "EpochMetric", description = "Epoch별 성능 지표")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class EpochMetric {
private Integer epoch;
private Double mIoU;
private Double mFscore;
private Double loss;
private Boolean isBest;
}
}

View File

@@ -0,0 +1,61 @@
package com.kamco.cd.training.model.dto;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.ZonedDateTime;
import lombok.Getter;
public class ModelVerDto {
@Schema(name = "modelVer Basic", description = "모델버전 엔티티 기본 정보")
@Getter
public static class Basic {
private final Long id;
private final Long modelUid;
private final String modelCate;
private final String modelVer;
private final String usedState;
private final String modelState;
private final Double qualityProb;
private final String deployState;
private final String modelPath;
@JsonFormatDttm private final ZonedDateTime createdDttm;
private final Long createdUid;
@JsonFormatDttm private final ZonedDateTime updatedDttm;
private final Long updatedUid;
public Basic(
Long id,
Long modelUid,
String modelCate,
String modelVer,
String usedState,
String modelState,
Double qualityProb,
String deployState,
String modelPath,
ZonedDateTime createdDttm,
Long createdUid,
ZonedDateTime updatedDttm,
Long updatedUid) {
this.id = id;
this.modelUid = modelUid;
this.modelCate = modelCate;
this.modelVer = modelVer;
this.usedState = usedState;
this.modelState = modelState;
this.qualityProb = qualityProb;
this.deployState = deployState;
this.modelPath = modelPath;
this.createdDttm = createdDttm;
this.createdUid = createdUid;
this.updatedDttm = updatedDttm;
this.updatedUid = updatedUid;
}
}
}

View File

@@ -0,0 +1,50 @@
package com.kamco.cd.training.model.service;
import com.kamco.cd.training.model.dto.ModelMngDto;
import com.kamco.cd.training.model.dto.ModelMngDto.Basic;
import com.kamco.cd.training.model.dto.ModelMngDto.SearchReq;
import com.kamco.cd.training.postgres.core.ModelMngCoreService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class ModelMngService {
private final ModelMngCoreService modelMngCoreService;
/**
* 모델 목록 조회
*
* @param searchReq 검색 조건
* @return 페이징 처리된 모델 목록
*/
public Page<Basic> findByModels(SearchReq searchReq) {
return modelMngCoreService.findByModels(searchReq);
}
/**
* 모델 상세 조회
*
* @param modelUid 모델 UID
* @return 모델 상세 정보
*/
public ModelMngDto.Detail getModelDetail(Long modelUid) {
return modelMngCoreService.getModelDetail(modelUid);
}
/**
* 모델 상세 조회 (UUID 기반)
*
* @param uuid 모델 UUID
* @return 모델 상세 정보
*/
public ModelMngDto.Detail getModelDetailByUuid(String uuid) {
return modelMngCoreService.getModelDetailByUuid(uuid);
}
}

View File

@@ -0,0 +1,393 @@
package com.kamco.cd.training.model.service;
import com.kamco.cd.training.common.exception.BadRequestException;
import com.kamco.cd.training.common.exception.NotFoundException;
import com.kamco.cd.training.model.dto.ModelMngDto;
import com.kamco.cd.training.postgres.core.DatasetCoreService;
import com.kamco.cd.training.postgres.core.HyperParamCoreService;
import com.kamco.cd.training.postgres.core.ModelMngCoreService;
import com.kamco.cd.training.postgres.core.SystemMetricsCoreService;
import com.kamco.cd.training.postgres.entity.ModelTrainMasterEntity;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class ModelTrainService {
private final ModelMngCoreService modelMngCoreService;
private final HyperParamCoreService hyperParamCoreService;
private final DatasetCoreService datasetCoreService;
private final SystemMetricsCoreService systemMetricsCoreService;
/**
* 학습 모델 목록 조회
*
* @return 학습 모델 목록
*/
public List<ModelMngDto.TrainListRes> getTrainModelList() {
return modelMngCoreService.findAllTrainModels();
}
/**
* 학습 설정 통합 조회
*
* @return 학습 설정 폼 데이터
*/
public ModelMngDto.FormConfigRes getFormConfig() {
// 1. 현재 실행 중인 모델 확인
String runningModelUuid = modelMngCoreService.findRunningModelUuid();
boolean isTrainAvailable = (runningModelUuid == null);
// 2. 저장공간 체크 (10GB 미만 시 학습 불가)
if (isTrainAvailable) {
isTrainAvailable = systemMetricsCoreService.isStorageAvailableForTraining();
long availableMB = systemMetricsCoreService.getAvailableStorageMB();
log.info("저장공간 체크 완료: {}MB 사용 가능, 학습 가능 여부: {}", availableMB, isTrainAvailable);
}
// 3. 하이퍼파라미터 목록
List<ModelMngDto.HyperParamInfo> hyperParams = hyperParamCoreService.findAllActiveHyperParams();
// 4. 데이터셋 목록
List<ModelMngDto.DatasetInfo> datasets = datasetCoreService.findAllActiveDatasetsForTraining();
return ModelMngDto.FormConfigRes.builder()
.isTrainAvailable(isTrainAvailable)
.hyperParams(hyperParams)
.datasets(datasets)
.runningModelUuid(runningModelUuid)
.build();
}
/**
* 하이퍼파라미터 등록
*
* @param createReq 등록 요청
* @return 생성된 버전명
*/
@Transactional
public String createHyperParam(ModelMngDto.HyperParamCreateReq createReq) {
// 신규 버전 추가 시 baseHyperVer가 없으면 H1으로 설정
if (createReq.getBaseHyperVer() == null || createReq.getBaseHyperVer().isEmpty()) {
String firstVersion = hyperParamCoreService.getFirstHyperParamVersion();
createReq.setBaseHyperVer(firstVersion);
log.info("baseHyperVer가 없어 첫 번째 버전으로 설정: {}", firstVersion);
}
String newVersion = hyperParamCoreService.createHyperParam(createReq);
log.info("하이퍼파라미터 등록 완료: {}", newVersion);
return newVersion;
}
/**
* 하이퍼파라미터 단건 조회
*
* @param hyperVer 하이퍼파라미터 버전
* @return 하이퍼파라미터 정보
*/
public ModelMngDto.HyperParamInfo getHyperParam(String hyperVer) {
return hyperParamCoreService.findByHyperVer(hyperVer);
}
/**
* 하이퍼파라미터 삭제
*
* @param hyperVer 하이퍼파라미터 버전
*/
@Transactional
public void deleteHyperParam(String hyperVer) {
hyperParamCoreService.deleteHyperParam(hyperVer);
log.info("하이퍼파라미터 삭제 완료: {}", hyperVer);
}
/**
* 학습 시작
*
* @param trainReq 학습 시작 요청
* @return 학습 시작 응답
*/
@Transactional
public ModelMngDto.TrainStartRes startTraining(ModelMngDto.TrainStartReq trainReq) {
// 1. 동시 실행 방지 검증
String runningModelUuid = modelMngCoreService.findRunningModelUuid();
if (runningModelUuid != null) {
throw new BadRequestException(
"이미 실행 중인 학습이 있습니다. 학습은 한 번에 한 개만 실행할 수 있습니다. (실행 중인 모델: " + runningModelUuid + ")");
}
// 2. 저장공간 체크 (10GB 미만 시 학습 불가)
if (!systemMetricsCoreService.isStorageAvailableForTraining()) {
long availableMB = systemMetricsCoreService.getAvailableStorageMB();
long requiredMB = 10 * 1024; // 10GB
throw new BadRequestException(
String.format(
"저장공간이 부족하여 학습을 시작할 수 없습니다. (필요: %dMB, 사용 가능: %dMB)", requiredMB, availableMB));
}
// 3. 데이터셋 상태 검증 (COMPLETED 상태만 학습 가능)
validateDatasetStatus(trainReq.getDatasetIds());
// 4. 데이터 분할 비율 검증 (예: "7:2:1" 형식)
if (trainReq.getDatasetRatio() != null && !trainReq.getDatasetRatio().isEmpty()) {
validateDatasetRatio(trainReq.getDatasetRatio());
}
// 5. 학습 마스터 생성
ModelTrainMasterEntity entity = modelMngCoreService.createTrainMaster(trainReq);
// 5. 데이터셋 매핑 생성
modelMngCoreService.createDatasetMappings(entity.getId(), trainReq.getDatasetIds());
// 6. 실제 UUID 사용
String uuid = entity.getUuid().toString();
log.info(
"학습 시작: uuid={}, hyperVer={}, epoch={}, datasets={}",
uuid,
trainReq.getHyperVer(),
trainReq.getEpoch(),
trainReq.getDatasetIds());
// TODO: 비동기 GPU 학습 프로세스 트리거 로직 추가
return ModelMngDto.TrainStartRes.builder().uuid(uuid).status(entity.getStatusCd()).build();
}
/**
* 데이터셋 상태 검증
*
* @param datasetIds 데이터셋 ID 목록
*/
private void validateDatasetStatus(List<Long> datasetIds) {
for (Long datasetId : datasetIds) {
try {
var dataset = datasetCoreService.getOneById(datasetId);
// COMPLETED 상태가 아닌 데이터셋이 포함되어 있으면 예외 발생
if (dataset.getStatus() == null || !"COMPLETED".equals(dataset.getStatus())) {
throw new BadRequestException(
String.format(
"학습에 사용할 수 없는 데이터셋입니다. (ID: %d, 상태: %s). COMPLETED 상태의 데이터셋만 선택 가능합니다.",
datasetId, dataset.getStatus() != null ? dataset.getStatus() : "NULL"));
}
log.debug("데이터셋 상태 검증 통과: ID={}, Status={}", datasetId, dataset.getStatus());
} catch (NotFoundException e) {
throw new BadRequestException("존재하지 않는 데이터셋입니다. ID: " + datasetId);
}
}
log.info("모든 데이터셋 상태 검증 완료: {} 개", datasetIds.size());
}
/**
* 데이터 분할 비율 검증
*
* @param datasetRatio 데이터셋 비율 (예: "7:2:1")
*/
private void validateDatasetRatio(String datasetRatio) {
try {
String[] parts = datasetRatio.split(":");
if (parts.length != 3) {
throw new BadRequestException("데이터 분할 비율은 'Training:Validation:Test' 형식이어야 합니다 (예: 7:2:1)");
}
int train = Integer.parseInt(parts[0].trim());
int validation = Integer.parseInt(parts[1].trim());
int test = Integer.parseInt(parts[2].trim());
int sum = train + validation + test;
if (sum != 10) {
throw new BadRequestException(
String.format("데이터 분할 비율의 합계는 10이어야 합니다. (현재 합계: %d, 입력값: %s)", sum, datasetRatio));
}
if (train <= 0 || validation < 0 || test < 0) {
throw new BadRequestException("데이터 분할 비율은 모두 0 이상이어야 합니다 (Training은 1 이상)");
}
log.info(
"데이터 분할 비율 검증 완료: Training={}0%, Validation={}0%, Test={}0%", train, validation, test);
} catch (NumberFormatException e) {
throw new BadRequestException("데이터 분할 비율은 숫자로만 구성되어야 합니다: " + datasetRatio);
}
}
/**
* 학습 모델 삭제
*
* @param uuid 모델 UUID
*/
@Transactional
public void deleteTrainModel(String uuid) {
modelMngCoreService.deleteByUuid(uuid);
log.info("학습 모델 삭제 완료: uuid={}", uuid);
}
// ==================== Resume Training (학습 재시작) ====================
/**
* 학습 재시작 정보 조회
*
* @param uuid 모델 UUID
* @return 재시작 정보
*/
public ModelMngDto.ResumeInfo getResumeInfo(String uuid) {
ModelTrainMasterEntity entity = modelMngCoreService.findByUuid(uuid);
return ModelMngDto.ResumeInfo.builder()
.canResume(entity.getCanResume() != null && entity.getCanResume())
.lastEpoch(entity.getLastCheckpointEpoch())
.totalEpoch(entity.getEpochCnt())
.checkpointPath(entity.getCheckpointPath())
.failedAt(
entity.getStopDttm() != null
? entity.getStopDttm().atZone(java.time.ZoneId.systemDefault())
: null)
.build();
}
/**
* 학습 재시작
*
* @param uuid 모델 UUID
* @param resumeReq 재시작 요청
* @return 재시작 응답
*/
@Transactional
public ModelMngDto.ResumeResponse resumeTraining(
String uuid, ModelMngDto.ResumeRequest resumeReq) {
ModelTrainMasterEntity entity = modelMngCoreService.findByUuid(uuid);
// 재시작 가능 여부 검증
if (entity.getCanResume() == null || !entity.getCanResume()) {
throw new IllegalStateException("학습 재시작이 불가능한 모델입니다: " + uuid);
}
if (entity.getLastCheckpointEpoch() == null) {
throw new IllegalStateException("Checkpoint가 존재하지 않습니다: " + uuid);
}
// 상태 업데이트
entity.setStatusCd("RUNNING");
entity.setProgressRate(0);
// 총 Epoch 수 변경 (선택사항)
if (resumeReq.getNewTotalEpoch() != null) {
entity.setEpochCnt(resumeReq.getNewTotalEpoch());
}
log.info(
"학습 재시작: uuid={}, resumeFromEpoch={}, totalEpoch={}",
uuid,
resumeReq.getResumeFromEpoch(),
entity.getEpochCnt());
// TODO: 비동기 GPU 학습 재시작 프로세스 트리거 로직 추가
// - Checkpoint 파일 로드
// - 지정된 Epoch부터 학습 재개
return ModelMngDto.ResumeResponse.builder()
.uuid(uuid)
.status(entity.getStatusCd())
.resumedFromEpoch(resumeReq.getResumeFromEpoch())
.build();
}
// ==================== Best Epoch Setting (Best Epoch 설정) ====================
/**
* Best Epoch 설정
*
* @param uuid 모델 UUID
* @param bestEpochReq Best Epoch 요청
* @return Best Epoch 응답
*/
@Transactional
public ModelMngDto.BestEpochResponse setBestEpoch(
String uuid, ModelMngDto.BestEpochRequest bestEpochReq) {
ModelTrainMasterEntity entity = modelMngCoreService.findByUuid(uuid);
// 1차 학습 완료 상태 검증
if (!"STEP1_COMPLETED".equals(entity.getStatusCd())
&& !"STEP1".equals(entity.getProcessStep())) {
log.warn(
"Best Epoch 설정 시도: 현재 상태={}, processStep={}",
entity.getStatusCd(),
entity.getProcessStep());
}
Integer previousBestEpoch = entity.getConfirmedBestEpoch();
// 사용자가 확정한 Best Epoch 설정
entity.setConfirmedBestEpoch(bestEpochReq.getBestEpoch());
// 2차 학습(Test) 단계로 상태 전이
entity.setProcessStep("STEP2");
entity.setStatusCd("STEP2_RUNNING");
entity.setProgressRate(0);
entity.setUpdatedDttm(java.time.ZonedDateTime.now());
log.info(
"Best Epoch 설정 및 2차 학습 시작: uuid={}, newBestEpoch={}, previousBestEpoch={}, reason={}, newStatus={}",
uuid,
bestEpochReq.getBestEpoch(),
previousBestEpoch,
bestEpochReq.getReason(),
entity.getStatusCd());
// TODO: 비동기 GPU 2차 학습(Test) 프로세스 트리거 로직 추가
// - Best Epoch 모델 로드
// - Test 데이터셋으로 성능 평가 실행
// - 완료 시 STEP2_COMPLETED 상태로 전환
return ModelMngDto.BestEpochResponse.builder()
.uuid(uuid)
.bestEpoch(entity.getBestEpoch()) // 자동 선택된 값
.confirmedBestEpoch(entity.getConfirmedBestEpoch()) // 사용자 확정 값
.previousBestEpoch(previousBestEpoch)
.build();
}
/**
* Epoch별 성능 지표 조회
*
* @param uuid 모델 UUID
* @return Epoch별 성능 지표 목록
*/
public List<ModelMngDto.EpochMetric> getEpochMetrics(String uuid) {
ModelTrainMasterEntity entity = modelMngCoreService.findByUuid(uuid);
// TODO: 실제 학습 로그 파일이나 DB에서 Epoch별 성능 지표 조회
// 현재는 샘플 데이터 반환
List<ModelMngDto.EpochMetric> metrics = new java.util.ArrayList<>();
if (entity.getEpochCnt() != null && entity.getBestEpoch() != null) {
// 샘플 데이터 생성 (실제로는 학습 로그 파일 파싱 또는 별도 테이블 조회)
for (int i = 1; i <= Math.min(entity.getEpochCnt(), 10); i++) {
int epoch = entity.getBestEpoch() - 5 + i;
if (epoch <= 0 || epoch > entity.getEpochCnt()) {
continue;
}
metrics.add(
ModelMngDto.EpochMetric.builder()
.epoch(epoch)
.mIoU(0.80 + (Math.random() * 0.15)) // 샘플 데이터
.mFscore(0.85 + (Math.random() * 0.10)) // 샘플 데이터
.loss(0.3 - (Math.random() * 0.15)) // 샘플 데이터
.isBest(entity.getBestEpoch() != null && epoch == entity.getBestEpoch())
.build());
}
}
log.info("Epoch별 성능 지표 조회: uuid={}, metricsCount={}", uuid, metrics.size());
return metrics;
}
}

View File

@@ -0,0 +1,22 @@
package com.kamco.cd.training.postgres;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import java.time.ZonedDateTime;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
@Getter
@MappedSuperclass
public class CommonCreateEntity {
@CreatedDate
@Column(name = "created_dttm", updatable = false, nullable = false)
private ZonedDateTime createdDate;
@PrePersist
protected void onPersist() {
this.createdDate = ZonedDateTime.now();
}
}

Some files were not shown because too many files have changed in this diff Show More