commit 05f068276d35b1a652a95d3b4cddbf4d6e9719ed Author: Hyojin Ahn Date: Tue Dec 23 14:58:10 2025 -0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..c1dd12f Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..b74bf7f --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3e7f79 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Spring Boot 3.0 Security with JWT Implementation +This project demonstrates the implementation of security using Spring Boot 3.0 and JSON Web Tokens (JWT). It includes the following features: + +## Features +* User registration and login with JWT authentication +* Password encryption using BCrypt +* Role-based authorization with Spring Security +* Customized access denied handling +* Logout mechanism +* Refresh token + +## Technologies +* Spring Boot 3.0 +* Spring Security +* JSON Web Tokens (JWT) +* BCrypt +* Maven + +## Getting Started +To get started with this project, you will need to have the following installed on your local machine: + +* JDK 17+ +* Maven 3+ + + +To build and run the project, follow these steps: + +* Clone the repository: `git clone https://github.com/ali-bouali/spring-boot-3-jwt-security.git` +* Navigate to the project directory: cd spring-boot-security-jwt +* Add database "jwt_security" to postgres +* Build the project: mvn clean install +* Run the project: mvn spring-boot:run + +-> The application will be available at http://localhost:8080. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c2973b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + container_name: postgres-sql + image: postgres + environment: + POSTGRES_USER: username + POSTGRES_PASSWORD: password + PGDATA: /data/postgres + volumes: + - postgres:/data/postgres + ports: + - "5432:5432" + networks: + - postgres + restart: unless-stopped + + pgadmin: + container_name: pgadmin + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + PGADMIN_CONFIG_SERVER_MODE: 'False' + volumes: + - pgadmin:/var/lib/pgadmin + ports: + - "5050:80" + networks: + - postgres + restart: unless-stopped + +networks: + postgres: + driver: bridge + +volumes: + postgres: + pgadmin: \ No newline at end of file diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..8a8fb22 --- /dev/null +++ b/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + 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 + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..1d8ab01 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. 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, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ca97d0f --- /dev/null +++ b/pom.xml @@ -0,0 +1,102 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.1.4 + + + com.goi + sys-rest-api + 0.0.1-SNAPSHOT + System + System Configuration REST Api + + 17 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-web + + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.1.0 + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + template + layered-architecture-template + 1.0.0-SNAPSHOT + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/com/goi/erp/SecurityApplication.java b/src/main/java/com/goi/erp/SecurityApplication.java new file mode 100644 index 0000000..295217b --- /dev/null +++ b/src/main/java/com/goi/erp/SecurityApplication.java @@ -0,0 +1,18 @@ +package com.goi.erp; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication(scanBasePackages = {"com.goi.erp"}) +@EnableJpaAuditing(auditorAwareRef = "auditorAware") +@EntityScan(basePackages = {"com.goi.erp.entity"}) +@EnableJpaRepositories(basePackages = {"com.goi.erp.repository"}) +public class SecurityApplication { + + public static void main(String[] args) { + SpringApplication.run(SecurityApplication.class, args); + } +} diff --git a/src/main/java/com/goi/erp/common/exception/GlobalExceptionHandler.java b/src/main/java/com/goi/erp/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..70912b0 --- /dev/null +++ b/src/main/java/com/goi/erp/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,62 @@ +package com.goi.erp.common.exception; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException ex) { + + Map body = new HashMap<>(); + body.put("error", ex.getMessage()); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.BAD_REQUEST.value()); + + return ResponseEntity.badRequest().body(body); + } + + // 모든 예외 처리 + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAllExceptions(Exception ex) { + Map body = new HashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + body.put("error", "Internal Server Error"); + body.put("message", ex.getMessage()); + return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR); + } + + // 권한 없음 + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException ex) { + Map body = new HashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.FORBIDDEN.value()); + body.put("error", "Forbidden"); + body.put("message", ex.getMessage()); + return new ResponseEntity<>(body, HttpStatus.FORBIDDEN); + } + + // + @ExceptionHandler(JwtExpiredException.class) + public ResponseEntity> handleJwtExpired(JwtExpiredException ex) { + Map body = Map.of("error", "JWT expired", "message", ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body); + } + + @ExceptionHandler(JwtInvalidException.class) + public ResponseEntity> handleJwtInvalid(JwtInvalidException ex) { + Map body = Map.of("error", "Invalid JWT", "message", ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body); + } +} + diff --git a/src/main/java/com/goi/erp/common/exception/JwtExpiredException.java b/src/main/java/com/goi/erp/common/exception/JwtExpiredException.java new file mode 100644 index 0000000..134ab08 --- /dev/null +++ b/src/main/java/com/goi/erp/common/exception/JwtExpiredException.java @@ -0,0 +1,27 @@ +package com.goi.erp.common.exception; + +public class JwtExpiredException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public JwtExpiredException() { + super(); + } + + public JwtExpiredException(String message) { + super(message); + } + + public JwtExpiredException(String message, Throwable cause) { + super(message, cause); + } + + public JwtExpiredException(Throwable cause) { + super(cause); + } + + protected JwtExpiredException(String message, Throwable cause, + boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/com/goi/erp/common/exception/JwtInvalidException.java b/src/main/java/com/goi/erp/common/exception/JwtInvalidException.java new file mode 100644 index 0000000..06cb162 --- /dev/null +++ b/src/main/java/com/goi/erp/common/exception/JwtInvalidException.java @@ -0,0 +1,27 @@ +package com.goi.erp.common.exception; + +public class JwtInvalidException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public JwtInvalidException() { + super(); + } + + public JwtInvalidException(String message) { + super(message); + } + + public JwtInvalidException(String message, Throwable cause) { + super(message, cause); + } + + public JwtInvalidException(Throwable cause) { + super(cause); + } + + protected JwtInvalidException(String message, Throwable cause, + boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/com/goi/erp/common/permission/Permission.java b/src/main/java/com/goi/erp/common/permission/Permission.java new file mode 100644 index 0000000..cea0537 --- /dev/null +++ b/src/main/java/com/goi/erp/common/permission/Permission.java @@ -0,0 +1,18 @@ +package com.goi.erp.common.permission; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Permission { + private PermissionEnums.Module module; + private PermissionEnums.Action action; + private PermissionEnums.Scope scope; + private final boolean all; + + public boolean isAll() { + return all || module == PermissionEnums.Module.ALL; + } +} + diff --git a/src/main/java/com/goi/erp/common/permission/PermissionChecker.java b/src/main/java/com/goi/erp/common/permission/PermissionChecker.java new file mode 100644 index 0000000..31a3387 --- /dev/null +++ b/src/main/java/com/goi/erp/common/permission/PermissionChecker.java @@ -0,0 +1,36 @@ +package com.goi.erp.common.permission; + +public class PermissionChecker { + + public static boolean canCreateCRM(PermissionSet set) { + if (set.hasAll()) return true; + return set.has(PermissionEnums.Module.C, PermissionEnums.Action.C); + } + + public static boolean canReadCRM(PermissionSet set) { + if (set.hasAll()) return true; + return set.has(PermissionEnums.Module.C, PermissionEnums.Action.R); + } + + public static boolean canUpdateCRM(PermissionSet set) { + if (set.hasAll()) return true; + return set.has(PermissionEnums.Module.C, PermissionEnums.Action.U); + } + + public static boolean canDeleteCRM(PermissionSet set) { + if (set.hasAll()) return true; + return set.has(PermissionEnums.Module.C, PermissionEnums.Action.D); + } + + // 범위까지 체크 + public static boolean canReadCRMAll(PermissionSet set) { + if (set.hasAll()) return true; + return set.hasFull( + PermissionEnums.Module.C, + PermissionEnums.Action.R, + PermissionEnums.Scope.A + ); + } +} + + diff --git a/src/main/java/com/goi/erp/common/permission/PermissionEnums.java b/src/main/java/com/goi/erp/common/permission/PermissionEnums.java new file mode 100644 index 0000000..00562cc --- /dev/null +++ b/src/main/java/com/goi/erp/common/permission/PermissionEnums.java @@ -0,0 +1,22 @@ +package com.goi.erp.common.permission; + +public class PermissionEnums { + + public enum Module { + H, // HCM + C, // CRM + A, // ACC + O, // OPERATION + S, // SYSTEM + ALL // ADMIN + } + + public enum Action { + C, R, U, D + } + + public enum Scope { + S, P, A + } +} + diff --git a/src/main/java/com/goi/erp/common/permission/PermissionParser.java b/src/main/java/com/goi/erp/common/permission/PermissionParser.java new file mode 100644 index 0000000..14a5bf3 --- /dev/null +++ b/src/main/java/com/goi/erp/common/permission/PermissionParser.java @@ -0,0 +1,32 @@ +package com.goi.erp.common.permission; + +import java.util.ArrayList; +import java.util.List; + +public class PermissionParser { + + public static PermissionSet parse(List permissionStrings) { + + List list = new ArrayList<>(); + + for (String str : permissionStrings) { + // ALL 권한 추가 + if ("ALL".equalsIgnoreCase(str)) { + list.add(new Permission(PermissionEnums.Module.ALL, null, null, true)); + continue; + } + // 문자 세개 조합 인지 확인 + String[] parts = str.split(":"); + if (parts.length != 3) continue; + + PermissionEnums.Module module = PermissionEnums.Module.valueOf(parts[0]); + PermissionEnums.Action action = PermissionEnums.Action.valueOf(parts[1]); + PermissionEnums.Scope scope = PermissionEnums.Scope.valueOf(parts[2]); + // + list.add(new Permission(module, action, scope, false)); + } + + return new PermissionSet(list); + } +} + diff --git a/src/main/java/com/goi/erp/common/permission/PermissionSet.java b/src/main/java/com/goi/erp/common/permission/PermissionSet.java new file mode 100644 index 0000000..a5dc68a --- /dev/null +++ b/src/main/java/com/goi/erp/common/permission/PermissionSet.java @@ -0,0 +1,26 @@ +package com.goi.erp.common.permission; + +import java.util.List; + +public record PermissionSet(List permissions) { + + public boolean has(PermissionEnums.Module module, + PermissionEnums.Action action) { + return permissions.stream() + .anyMatch(p -> p.getModule() == module && + p.getAction() == action); + } + + public boolean hasFull(PermissionEnums.Module module, + PermissionEnums.Action action, + PermissionEnums.Scope scope) { + return permissions.stream() + .anyMatch(p -> p.getModule() == module && + p.getAction() == action && + p.getScope().ordinal() >= scope.ordinal()); + } + + public boolean hasAll() { + return permissions.stream().anyMatch(p -> p.isAll()); + } +} diff --git a/src/main/java/com/goi/erp/config/ApplicationConfig.java b/src/main/java/com/goi/erp/config/ApplicationConfig.java new file mode 100644 index 0000000..c2805bb --- /dev/null +++ b/src/main/java/com/goi/erp/config/ApplicationConfig.java @@ -0,0 +1,21 @@ +package com.goi.erp.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; + +import com.goi.erp.token.ApplicationAuditAware; + +@Configuration +public class ApplicationConfig { + + @Value("${application.security.jwt.secret-key}") + private String jwtSecret; + + @Bean + public AuditorAware auditorAware() { + return new ApplicationAuditAware(jwtSecret); + } + +} diff --git a/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java b/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a204963 --- /dev/null +++ b/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java @@ -0,0 +1,98 @@ +package com.goi.erp.config; + +import com.goi.erp.common.permission.PermissionSet; +import com.goi.erp.token.JwtService; +import com.goi.erp.token.PermissionAuthenticationToken; +import io.jsonwebtoken.ExpiredJwtException; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + final String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + final String jwt = authHeader.substring(7); + + try { + // 기존 인증 정보 확인 + var authentication = SecurityContextHolder.getContext().getAuthentication(); + boolean needsAuthentication = true; + + if (authentication instanceof PermissionAuthenticationToken token) { + // PermissionSet이 이미 존재하면 새로 세팅할 필요 없음 + needsAuthentication = token.getPermissionSet() == null; + } else if (authentication != null) { + // 다른 타입의 Authentication이 존재하면 덮어쓰지 않음 + needsAuthentication = false; + } + + if (needsAuthentication && jwtService.isTokenValid(jwt)) { + + // 토큰에서 loginId와 PermissionSet 추출 + String loginId = jwtService.extractLoginId(jwt); + PermissionSet permissionSet = jwtService.getPermissions(jwt); + + if (permissionSet == null) { + permissionSet = new PermissionSet(List.of()); // 빈 PermissionSet으로 초기화 + } + + // SimpleGrantedAuthority 생성 + List authorities = permissionSet.permissions().stream() + .map(p -> new SimpleGrantedAuthority(p.toString())) // 필요시 커스텀 문자열로 변경 + .collect(Collectors.toList()); + + // PermissionAuthenticationToken 생성 + PermissionAuthenticationToken authToken = + new PermissionAuthenticationToken( + loginId, + jwt, // 토큰 저장 + permissionSet, + authorities + ); + + // SecurityContextHolder에 세팅 + SecurityContextHolder.getContext().setAuthentication(authToken); + } + + } catch (ExpiredJwtException e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"Session has expired.\"}"); + return; + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"Invalid login information.\"}"); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/goi/erp/config/OpenApiConfig.java b/src/main/java/com/goi/erp/config/OpenApiConfig.java new file mode 100644 index 0000000..1050bcc --- /dev/null +++ b/src/main/java/com/goi/erp/config/OpenApiConfig.java @@ -0,0 +1,54 @@ +package com.goi.erp.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; + +@OpenAPIDefinition( + info = @Info( + contact = @Contact( + name = "Alibou", + email = "contact@aliboucoding.com", + url = "https://aliboucoding.com/course" + ), + description = "OpenApi documentation for Spring Security", + title = "OpenApi specification - Alibou", + version = "1.0", + license = @License( + name = "Licence name", + url = "https://some-url.com" + ), + termsOfService = "Terms of service" + ), + servers = { + @Server( + description = "Local ENV", + url = "http://localhost:8080" + ), + @Server( + description = "PROD ENV", + url = "https://aliboucoding.com/course" + ) + }, + security = { + @SecurityRequirement( + name = "bearerAuth" + ) + } +) +@SecurityScheme( + name = "bearerAuth", + description = "JWT auth description", + scheme = "bearer", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER +) +public class OpenApiConfig { +} diff --git a/src/main/java/com/goi/erp/config/RestTemplateConfig.java b/src/main/java/com/goi/erp/config/RestTemplateConfig.java new file mode 100644 index 0000000..ccfd869 --- /dev/null +++ b/src/main/java/com/goi/erp/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.goi.erp.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/goi/erp/config/SecurityConfig.java b/src/main/java/com/goi/erp/config/SecurityConfig.java new file mode 100644 index 0000000..3dfe4ad --- /dev/null +++ b/src/main/java/com/goi/erp/config/SecurityConfig.java @@ -0,0 +1,59 @@ +package com.goi.erp.config; + +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +@EnableMethodSecurity // @PreAuthorize 등 사용 가능 +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthFilter; // JWT 인증 필터 + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) // CSRF 비활성화 (API 서버라면 stateless) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안함 + .authorizeHttpRequests(auth -> auth + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated() + ) // 요청 권한 설정 + .addFilterBefore(new CorsFilter(corsConfigurationSource()), UsernamePasswordAuthenticationFilter.class) // JWT 필터 전에 CorsFilter 등록 + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); // JWT 필터 + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList( + "http://192.168.2.172:8000", + "http://localhost:8000", + "http://127.0.0.1:8000", + "https://homotypical-bowen-unlanguid.ngrok-free.dev" + )); + configuration.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE","OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("Authorization","Content-Type")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + +} diff --git a/src/main/java/com/goi/erp/controller/CustomerController.java b/src/main/java/com/goi/erp/controller/CustomerController.java new file mode 100644 index 0000000..03167fd --- /dev/null +++ b/src/main/java/com/goi/erp/controller/CustomerController.java @@ -0,0 +1,208 @@ +package com.goi.erp.controller; + +import com.goi.erp.common.permission.PermissionChecker; +import com.goi.erp.common.permission.PermissionSet; +import com.goi.erp.dto.CustomerDailyOrderResponseDto; +import com.goi.erp.dto.CustomerRequestDto; +import com.goi.erp.dto.CustomerResponseDto; +import com.goi.erp.service.CustomerDailyOrderService; +import com.goi.erp.service.CustomerService; +import com.goi.erp.token.PermissionAuthenticationToken; + +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.UUID; + +@RestController +@RequestMapping("/customer") +@RequiredArgsConstructor +public class CustomerController { + @Value("${pagination.default-page:0}") + private int defaultPage; + + @Value("${pagination.default-size:20}") + private int defaultSize; + + @Value("${pagination.max-size:100}") + private int maxSize; + + private final CustomerService customerService; + private final CustomerDailyOrderService dailyOrderService; + + // CREATE + @PostMapping + public ResponseEntity createCustomer(@RequestBody CustomerRequestDto requestDto) { + // 권한 체크 + PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canCreateCRM(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read all CRM data"); + } + + CustomerResponseDto responseDto = customerService.createCustomer(requestDto); + return new ResponseEntity<>(responseDto, HttpStatus.CREATED); + } + + // READ ALL + @GetMapping + public ResponseEntity> getAllCustomers( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size + ) { + // 권한 체크 + PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canReadCRMAll(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read all CRM data"); + } + + // + int p = (page == null) ? defaultPage : page; + int s = (size == null) ? defaultSize : size; + if (s > maxSize) s = maxSize; + + // + return ResponseEntity.ok(customerService.getAllCustomers(p, s)); + } + + // READ ONE + @GetMapping("/uuid/{uuid}") + public ResponseEntity getCustomer(@PathVariable UUID uuid) { + // 권한 체크 + PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canReadCRM(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read all CRM data"); + } + + return ResponseEntity.ok(customerService.getCustomerByUuid(uuid)); + } + + // UPDATE + @PatchMapping("/uuid/{uuid}") + public ResponseEntity updateCustomer( + @PathVariable UUID uuid, + @RequestBody CustomerRequestDto requestDto) { + // 권한 체크 + PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canUpdateCRM(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read all CRM data"); + } + + return ResponseEntity.ok(customerService.updateCustomer(uuid, requestDto)); + } + + // DELETE + @DeleteMapping("/uuid/{uuid}") + public ResponseEntity deleteCustomer(@PathVariable UUID uuid) { + // 권한 체크 + PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canDeleteCRM(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read all CRM data"); + } + + customerService.deleteCustomer(uuid); + return ResponseEntity.noContent().build(); + } + + // from MIS + @GetMapping("/no/{cusNo}") + public ResponseEntity getCustomer(@PathVariable String cusNo) { + // 권한 체크 + PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canDeleteCRM(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read all CRM data"); + } + + // + CustomerResponseDto customer = customerService.getCustomerByNo(cusNo); + return ResponseEntity.ok(customer); + } + + @PatchMapping("/no/{cusNo}") + public ResponseEntity updateCustomer(@PathVariable String cusNo, + @RequestBody CustomerRequestDto dto) { + // 권한 체크 + PermissionAuthenticationToken auth = (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canDeleteCRM(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read all CRM data"); + } + + // + CustomerResponseDto updated = customerService.updateCustomerByNo(cusNo, dto); + return ResponseEntity.ok(updated); + } + + // READ DAILY ORDER BY CUSTOMER NO + DATE + @GetMapping("/no/{cusNo}/daily-orders/{orderDate}") + public ResponseEntity getDailyOrderByCustomerNo( + @PathVariable String cusNo, + @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate orderDate + ) { + + PermissionAuthenticationToken auth = + (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canReadCRM(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read daily order"); + } + + return ResponseEntity.ok( + dailyOrderService.getDailyOrderByCustomerNo(cusNo, orderDate) + ); + } +} diff --git a/src/main/java/com/goi/erp/controller/CustomerDailyOrderController.java b/src/main/java/com/goi/erp/controller/CustomerDailyOrderController.java new file mode 100644 index 0000000..6c9e640 --- /dev/null +++ b/src/main/java/com/goi/erp/controller/CustomerDailyOrderController.java @@ -0,0 +1,165 @@ +package com.goi.erp.controller; + +import com.goi.erp.common.permission.PermissionChecker; +import com.goi.erp.common.permission.PermissionSet; + +import com.goi.erp.dto.CustomerDailyOrderRequestDto; +import com.goi.erp.dto.CustomerDailyOrderResponseDto; +import com.goi.erp.service.CustomerDailyOrderService; +import com.goi.erp.token.PermissionAuthenticationToken; + +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; + +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.UUID; + +@RestController +@RequestMapping("/customer-daily-order") +@RequiredArgsConstructor +public class CustomerDailyOrderController { + + @Value("${pagination.default-page:0}") + private int defaultPage; + + @Value("${pagination.default-size:20}") + private int defaultSize; + + @Value("${pagination.max-size:100}") + private int maxSize; + + private final CustomerDailyOrderService dailyOrderService; + + private PermissionSet getPermission() { + PermissionAuthenticationToken auth = + (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + + return auth.getPermissionSet(); + } + + + // CREATE + @PostMapping + public ResponseEntity createDailyOrder( + @RequestBody CustomerDailyOrderRequestDto dto) { + + PermissionSet permissions = getPermission(); + if (!PermissionChecker.canCreateCRM(permissions)) { + throw new AccessDeniedException("You do not have permission to create daily orders"); + } + + CustomerDailyOrderResponseDto created = dailyOrderService.createDailyOrder(dto); + return new ResponseEntity<>(created, HttpStatus.CREATED); + } + + + // READ ALL (paged) + @GetMapping + public ResponseEntity> getAllDailyOrders( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size) { + + PermissionSet permissions = getPermission(); + if (!PermissionChecker.canReadCRMAll(permissions)) { + throw new AccessDeniedException("You do not have permission to read all daily orders"); + } + + int p = (page == null) ? defaultPage : page; + int s = (size == null) ? defaultSize : size; + if (s > maxSize) s = maxSize; + + return ResponseEntity.ok(dailyOrderService.getAllDailyOrders(p, s)); + } + + + // READ ONE BY UUID + @GetMapping("/{uuid}") + public ResponseEntity getDailyOrder(@PathVariable UUID uuid) { + + PermissionSet permissions = getPermission(); + if (!PermissionChecker.canReadCRM(permissions)) { + throw new AccessDeniedException("You do not have permission to read daily order"); + } + + return ResponseEntity.ok(dailyOrderService.getDailyOrderByUuid(uuid)); + } + + + // READ BY CUSTOMER + DATE + @GetMapping("/customer/{customerNo}/{orderDate}") + public ResponseEntity getDailyOrderByCustomerNo( + @PathVariable String customerNo, + @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate orderDate + ) + { + + PermissionSet permissions = getPermission(); + if (!PermissionChecker.canReadCRM(permissions)) { + throw new AccessDeniedException("You do not have permission to read daily order"); + } + + return ResponseEntity.ok(dailyOrderService.getDailyOrderByCustomerNo(customerNo, orderDate)); + } + + // UPDATE BY CUSTOMER + DATE + @PatchMapping("/customer/{customerNo}/{orderDate}") + public ResponseEntity updateDailyOrderByCustomerNo( + @PathVariable String customerNo, + @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate orderDate, + @RequestBody CustomerDailyOrderRequestDto dto + ) { + + PermissionSet permissions = getPermission(); + if (!PermissionChecker.canUpdateCRM(permissions)) { + throw new AccessDeniedException("You do not have permission to update daily order"); + } + + return ResponseEntity.ok( + dailyOrderService.updateDailyOrderByCustomerNoAndOrderDate(customerNo, orderDate, dto) + ); + } + + + + // UPDATE + @PatchMapping("/{uuid}") + public ResponseEntity updateDailyOrder( + @PathVariable UUID uuid, + @RequestBody CustomerDailyOrderRequestDto dto) { + + PermissionSet permissions = getPermission(); + if (!PermissionChecker.canUpdateCRM(permissions)) { + throw new AccessDeniedException("You do not have permission to update daily order"); + } + + return ResponseEntity.ok(dailyOrderService.updateDailyOrder(uuid, dto)); + } + + + // DELETE + @DeleteMapping("/{uuid}") + public ResponseEntity deleteDailyOrder(@PathVariable UUID uuid) { + + PermissionSet permissions = getPermission(); + if (!PermissionChecker.canDeleteCRM(permissions)) { + throw new AccessDeniedException("You do not have permission to delete daily order"); + } + + dailyOrderService.deleteDailyOrder(uuid); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/goi/erp/dto/CustomerDailyOrderRequestDto.java b/src/main/java/com/goi/erp/dto/CustomerDailyOrderRequestDto.java new file mode 100644 index 0000000..0be5933 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/CustomerDailyOrderRequestDto.java @@ -0,0 +1,44 @@ +package com.goi.erp.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +public class CustomerDailyOrderRequestDto { + + private LocalDate cdoOrderDate; // 주문 날짜 (YYYY-MM-DD) + private String cdoOrderType; // 주문 타입 ('N' 등) + private String cdoRequestNote; // 주문 요청 메모 + private String cdoExternalDriverId; // MIS 에서 driverId 로 호출해서 employee_external_map 참조해야함 + private Long cdoDriverId; // 배정된 driver id + private UUID cdoDriverUuid; // ERP 에서는 uuid 로 호출 + private Long cdoCustomerId; // 고객 ID + private String cdoCustomerNo; + private UUID cdoCustomerUuid; + private String cdoPaymentType; // 결제 타입 + private String cdoCycle; // Cycle (방문 주기) + private BigDecimal cdoRate; // 요율 (리터당 가격 등) + private String cdoCreatedBy; // 생성자 ID + private String cdoUpdatedBy; // 수정자 ID + private String cdoStatus; // 상태 ('A', 'F', 'D') + private String cdoVisitFlag; // 방문 여부 Flag + private BigDecimal cdoEstimatedQty; // 예상 수거량 + private BigDecimal cdoQuantity; // 실제 수거량 + private Integer cdoSludge; // Sludge 여부/수치 + private String cdoPayStatus; // 결제 여부 ('N', 'Y') + private BigDecimal cdoPayAmount; // 결제 금액 + private String cdoPayeeName; // Payee 이름 + private String cdoPayeeSign; // Payee 사인 (이미지 경로 등) + private LocalDateTime cdoPickupAt; // 입력 시간 + private String cdoPickupNote; // 주문 요청 메모 + private BigDecimal cdoPickupLat; // 픽업 위도 + private BigDecimal cdoPickupLon; // 픽업 경도 + private Integer cdoPickupMin; // 픽업 작업 시간(분) + private String cdoLoginUser; // 로그인한 User (CreatedBy/UpdatedBy 용) +} diff --git a/src/main/java/com/goi/erp/dto/CustomerDailyOrderResponseDto.java b/src/main/java/com/goi/erp/dto/CustomerDailyOrderResponseDto.java new file mode 100644 index 0000000..4611fe7 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/CustomerDailyOrderResponseDto.java @@ -0,0 +1,47 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CustomerDailyOrderResponseDto { + + private UUID cdoUuid; // UUID + private LocalDate cdoOrderDate; // YYYYMMDD + private String cdoOrderType; // 주문 타입 + private String cdoRequestNote; // 요청 메모 + //private Long cdoDriverId; // Driver ID + //private Long cdoCustomerId; // + private String cdoCustomerNo; + private String cdoPaymentType; // 결제 방식 + private String cdoCycle; // Cycle 값 + private BigDecimal cdoRate; // 요율 + private LocalDateTime cdoCreatedAt; // 생성 시간 + private String cdoCreatedBy; // 생성자 + private LocalDateTime cdoUpdatedAt; // 수정 시간 + private String cdoUpdatedBy; // 수정자 + private String cdoStatus; // 상태 + private String cdoVisitFlag; // 방문 여부 + private BigDecimal cdoEstimatedQty; // 예상 수거량 + private BigDecimal cdoQuantity; // 실제 수거량 + private Integer cdoSludge; // Sludge 값 + private String cdoPayStatus; // 결제 상태 + private BigDecimal cdoPayAmount; // 결제 금액 + private String cdoPayeeName; // Payee 이름 + private String cdoPayeeSign; // Payee 사인 + private LocalDateTime cdoPickupAt; // 입력 시간 + private String cdoPickupNote; // 요청 메모 + private BigDecimal cdoPickupLat; // 픽업 위도 + private BigDecimal cdoPickupLon; // 픽업 경도 + private Integer cdoPickupMin; // 픽업 시간(분) +} diff --git a/src/main/java/com/goi/erp/dto/CustomerRequestDto.java b/src/main/java/com/goi/erp/dto/CustomerRequestDto.java new file mode 100644 index 0000000..d353e55 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/CustomerRequestDto.java @@ -0,0 +1,56 @@ +package com.goi.erp.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +@NoArgsConstructor +public class CustomerRequestDto { + + private String cusNo; // c_accountno + private String cusName; // c_name + private String cusStatus; // c_status → ('A', 'I', 'D' 등) + private Long cusAreaId; // region / area mapping + private String cusAddress1; // c_address + private String cusAddress2; // c_mailingaddr or c_location + private String cusPostalCode; // c_postal + private String cusCity; // c_city + private String cusProvince; // c_province + private Double cusGeoLat; // c_geolat + private Double cusGeoLon; // c_geolon + private String cusEmail; // c_email + private String cusPhone; // c_phone + private String cusPhoneExt; // c_phoneext + private LocalDate cusContractDate; // c_contractdate + private String cusContractedBy; // c_contractby + private LocalDate cusInstallDate; // c_installdate + private BigDecimal cusFullCycle; // c_fullcycle + private Boolean cusFullCycleFlag; // c_fullcycleflag + private BigDecimal cusFullCycleForced; // c_fullcycleforced + private LocalDate cusFullCycleForcedDate; // c_forceddate + private String cusSchedule; // c_schedule + private String cusScheduledays; // c_scheduleday + private LocalDate cusLastPaidDate; // c_lastpaiddate + private Double cusRate; // c_rate + private String cusPayMethod; // c_paymenttype + private String cusAccountNo; // c_accountno + private LocalDate cusIsccDate; // c_form_eu + private LocalDate cusCorsiaDate; // c_form_corsia + private String cusHstNo; // c_hstno + private LocalDate cusTerminatedDate; + private String cusTerminationReason; + private String cusInstallLocation; + private LocalDate cusLastPickupDate; + private Integer cusLastPickupQty; + private Double cusLastPickupLat; + private Double cusLastPickupLon; + private String cusOpenTime; + private String cusComment; // c_comment_ri + private String cusContactComment; // c_comment_ci + private Integer cusLastPickupMin; + private String cusLoginUser; +} + diff --git a/src/main/java/com/goi/erp/dto/CustomerResponseDto.java b/src/main/java/com/goi/erp/dto/CustomerResponseDto.java new file mode 100644 index 0000000..7c56517 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/CustomerResponseDto.java @@ -0,0 +1,57 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CustomerResponseDto { + private UUID cusUuid; + private String cusNo; + private String cusName; + private String cusStatus; + private String cusAddress1; + private String cusAddress2; + private String cusCity; + private String cusProvince; + private BigDecimal cusGeoLat; + private BigDecimal cusGeoLon; + private String cusEmail; + private String cusPhone; + private String cusPhoneExt; + private LocalDate cusContractDate; + private String cusContractedBy; + private LocalDate cusInstallDate; + private String cusInstallLocation; + private LocalDate cusLastPickupDate; + private Integer cusLastPickupQty; + private BigDecimal cusLastPickupLat; + private BigDecimal cusLastPickupLon; + private BigDecimal cusFullCycle; + private Boolean cusFullCycleFlag; + private BigDecimal cusFullCycleForced; + private LocalDate cusFullCycleForcedDate; + private String cusSchedule; + private String cusScheduledays; + private LocalDate cusLastPaidDate; + private BigDecimal cusRate; + private String cusPayMethod; + private String cusAccountNo; + private LocalDate cusIsccDate; + private LocalDate cusCorsiaDate; + private String cusHstNo; + private LocalDate cusTerminatedDate; + private String cusTerminationReason; + private Integer cusLastPickupMin; + private String cusOpenTime; + private String cusComment; + private String cusContactComment; +} diff --git a/src/main/java/com/goi/erp/entity/Customer.java b/src/main/java/com/goi/erp/entity/Customer.java new file mode 100644 index 0000000..c1a1afd --- /dev/null +++ b/src/main/java/com/goi/erp/entity/Customer.java @@ -0,0 +1,93 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Table(name = "customer") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Customer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long cusId; + + @Column(nullable = false, unique = true) + private UUID cusUuid; + + @Column(length = 50) + private String cusNo; + + @Column(nullable = false) + private String cusName; + + @Column(length = 1) + private String cusStatus; + + private Long cusAreaId; + private String cusAddress1; + private String cusAddress2; + private String cusPostalCode; + private String cusCity; + private String cusProvince; + private BigDecimal cusGeoLat; + private BigDecimal cusGeoLon; + private String cusEmail; + private String cusPhone; + private String cusPhoneExt; + private String cusOpenTime; + private String cusComment; + private String cusContactComment; + private LocalDate cusContractDate; + private String cusContractedBy; + private LocalDate cusInstallDate; + private String cusInstallLocation; + private LocalDate cusLastPickupDate; + private Integer cusLastPickupQty; + private BigDecimal cusLastPickupLat; + private BigDecimal cusLastPickupLon; + private BigDecimal cusFullCycle; + private Boolean cusFullCycleFlag; + private BigDecimal cusFullCycleForced; + private LocalDate cusFullCycleForcedDate; + private String cusSchedule; + private String cusScheduledays; + private LocalDate cusLastPaidDate; + private BigDecimal cusRate; + private String cusPayMethod; + private String cusAccountNo; + private LocalDate cusIsccDate; + private LocalDate cusCorsiaDate; + private String cusHstNo; + private LocalDate cusTerminatedDate; + private String cusTerminationReason; + private Integer cusLastPickupMin; + + @CreatedBy + private String cusCreatedBy; + + @LastModifiedBy + private String cusUpdatedBy; +} diff --git a/src/main/java/com/goi/erp/entity/CustomerDailyOrder.java b/src/main/java/com/goi/erp/entity/CustomerDailyOrder.java new file mode 100644 index 0000000..54cf2d1 --- /dev/null +++ b/src/main/java/com/goi/erp/entity/CustomerDailyOrder.java @@ -0,0 +1,107 @@ +package com.goi.erp.entity; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "customer_daily_order") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class CustomerDailyOrder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long cdoId; + + private UUID cdoUuid; + + private LocalDate cdoOrderDate; + + @Column(length = 1) + private String cdoOrderType; + + private String cdoRequestNote; + + private Long cdoDriverId; + + private Long cdoCustomerId; + private String cdoCustomerNo; + + @Column(length = 10) + private String cdoPaymentType; + + @Column(length = 1) + private String cdoCycle; + + private BigDecimal cdoRate; + + private LocalDateTime cdoCreatedAt; + + @CreatedBy + @Column(name = "cdo_created_by") + private String cdoCreatedBy; + + private LocalDateTime cdoUpdatedAt; + + @LastModifiedBy + @Column(name = "cdo_updated_by") + private String cdoUpdatedBy; + + @Column(length = 1) + private String cdoStatus; + + @Column(length = 1) + private String cdoVisitFlag; + + private LocalDateTime cdoPickupAt; + + private String cdoPickupNote; + + private BigDecimal cdoEstimatedQty; + + private BigDecimal cdoQuantity; + + private Integer cdoSludge; + + @Column(length = 1) + private String cdoPayStatus; + + private BigDecimal cdoPayAmount; + + @Column(length = 200) + private String cdoPayeeName; + + @Column(length = 200) + private String cdoPayeeSign; + + @Column(precision = 10, scale = 7) + private BigDecimal cdoPickupLat; + + @Column(precision = 10, scale = 7) + private BigDecimal cdoPickupLon; + + private Integer cdoPickupMin; +} diff --git a/src/main/java/com/goi/erp/entity/Employee.java b/src/main/java/com/goi/erp/entity/Employee.java new file mode 100644 index 0000000..0d0c7d4 --- /dev/null +++ b/src/main/java/com/goi/erp/entity/Employee.java @@ -0,0 +1,37 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "employee") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Employee { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "emp_id") + private Long empId; // 내부 PK, 외부 노출 X + + @Column(name = "emp_uuid", unique = true, nullable = false) + private UUID empUuid; // 외부 키로 사용 + + @Column(name = "emp_first_name") + private String empFirstName; + + @Column(name = "emp_last_name") + private String empLastName; +} + diff --git a/src/main/java/com/goi/erp/entity/EntityChangeLog.java b/src/main/java/com/goi/erp/entity/EntityChangeLog.java new file mode 100644 index 0000000..f7239bd --- /dev/null +++ b/src/main/java/com/goi/erp/entity/EntityChangeLog.java @@ -0,0 +1,65 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "entity_change_log") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EntityChangeLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ecl_id") + private Long eclId; + + @Column(name = "ecl_entity_type") + private String eclEntityType; + + @Column(name = "ecl_entity_id") + private Long eclEntityId; + + @Column(name = "ecl_field_name") + private String eclFieldName; + + @Column(name = "ecl_column_name") + private String eclColumnName; + + @Column(name = "ecl_old_value") + private String eclOldValue; + + @Column(name = "ecl_new_value") + private String eclNewValue; + + @Column(name = "ecl_effective_date") + private LocalDate eclEffectiveDate; + + @LastModifiedBy + @Column(name = "ecl_changed_by") + private String eclChangedBy; + + @Column(name = "ecl_changed_at") + private LocalDateTime eclChangedAt; + + @CreatedBy + @Column(name = "ecl_created_by") + private String eclCreatedBy; +} + diff --git a/src/main/java/com/goi/erp/repository/CustomerDailyOrderRepository.java b/src/main/java/com/goi/erp/repository/CustomerDailyOrderRepository.java new file mode 100644 index 0000000..75da40f --- /dev/null +++ b/src/main/java/com/goi/erp/repository/CustomerDailyOrderRepository.java @@ -0,0 +1,35 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.CustomerDailyOrder; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface CustomerDailyOrderRepository extends JpaRepository { + + // 기본 페이징 조회 + Page findAll(Pageable pageable); + + // UUID 로 조회 + Optional findByCdoUuid(UUID cdoUuid); + + // 특정 고객의 특정 날짜 주문 조회 + Optional findByCdoCustomerNoAndCdoOrderDate(String cdoCustomerNo, LocalDate cdoOrderDate); + + // 특정 고객의 모든 주문 리스트 + List findByCdoCustomerNo(String cdoCustomerNo); + + // 특정 날짜의 전체 주문 + List findByCdoOrderDate(LocalDate cdoOrderDate); + + // 존재 여부 체크 (중복 입력 방지) + boolean existsByCdoCustomerNoAndCdoOrderDate(String cdoCustomerNo, LocalDate cdoOrderDate); +} diff --git a/src/main/java/com/goi/erp/repository/CustomerRepository.java b/src/main/java/com/goi/erp/repository/CustomerRepository.java new file mode 100644 index 0000000..375de11 --- /dev/null +++ b/src/main/java/com/goi/erp/repository/CustomerRepository.java @@ -0,0 +1,22 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.Customer; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface CustomerRepository extends JpaRepository { + + Page findAll(Pageable pageable); + + Optional findByCusUuid(UUID cusUuid); + Optional findByCusNo(String cusNo); + + boolean existsByCusNo(String cusNo); +} diff --git a/src/main/java/com/goi/erp/repository/EntityChangeLogRepository.java b/src/main/java/com/goi/erp/repository/EntityChangeLogRepository.java new file mode 100644 index 0000000..e578e9e --- /dev/null +++ b/src/main/java/com/goi/erp/repository/EntityChangeLogRepository.java @@ -0,0 +1,10 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.EntityChangeLog; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EntityChangeLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/goi/erp/service/CustomerDailyOrderService.java b/src/main/java/com/goi/erp/service/CustomerDailyOrderService.java new file mode 100644 index 0000000..bd799e9 --- /dev/null +++ b/src/main/java/com/goi/erp/service/CustomerDailyOrderService.java @@ -0,0 +1,270 @@ +package com.goi.erp.service; + +import com.goi.erp.dto.CustomerDailyOrderRequestDto; +import com.goi.erp.dto.CustomerDailyOrderResponseDto; +import com.goi.erp.entity.CustomerDailyOrder; +import com.goi.erp.repository.CustomerDailyOrderRepository; +import com.goi.erp.repository.CustomerRepository; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CustomerDailyOrderService { + + private final CustomerDailyOrderRepository dailyOrderRepository; + private final CustomerRepository customerRepository; + private final HcmEmployeeClient hcmEmployeeClient; + + /** + * CREATE + */ + public CustomerDailyOrderResponseDto createDailyOrder(CustomerDailyOrderRequestDto dto) { + + // customer id + Long customerId = resolveCustomerId(dto); + + // driver id (employee id) + Long driverId = resolveDriverId(dto); + + CustomerDailyOrder order = CustomerDailyOrder.builder() + .cdoUuid(UUID.randomUUID()) + .cdoOrderDate(dto.getCdoOrderDate()) + .cdoOrderType(dto.getCdoOrderType()) + .cdoRequestNote(dto.getCdoRequestNote()) + .cdoDriverId(driverId) + .cdoCustomerId(customerId) + .cdoCustomerNo(dto.getCdoCustomerNo()) + .cdoPaymentType(dto.getCdoPaymentType()) + .cdoCycle(dto.getCdoCycle()) + .cdoRate(dto.getCdoRate()) + .cdoCreatedBy(dto.getCdoLoginUser()) + .cdoStatus(dto.getCdoStatus()) + .cdoVisitFlag(dto.getCdoVisitFlag()) + .cdoEstimatedQty(dto.getCdoEstimatedQty()) + .cdoQuantity(dto.getCdoQuantity()) + .cdoSludge(dto.getCdoSludge()) + .cdoPayStatus(dto.getCdoPayStatus()) + .cdoPayAmount(dto.getCdoPayAmount()) + .cdoPayeeName(dto.getCdoPayeeName()) + .cdoPayeeSign(dto.getCdoPayeeSign()) + .cdoPickupAt(dto.getCdoPickupAt()) + .cdoPickupNote(dto.getCdoPickupNote()) + .cdoPickupLat(dto.getCdoPickupLat()) + .cdoPickupLon(dto.getCdoPickupLon()) + .cdoPickupMin(dto.getCdoPickupMin()) + .build(); + + order = dailyOrderRepository.save(order); + return mapToDto(order); + } + + + /** + * GET ALL (with paging) + */ + public Page getAllDailyOrders(int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page orders = dailyOrderRepository.findAll(pageable); + return orders.map(this::mapToDto); + } + + + /** + * GET BY UUID + */ + public CustomerDailyOrderResponseDto getDailyOrderByUuid(UUID uuid) { + CustomerDailyOrder order = dailyOrderRepository.findByCdoUuid(uuid) + .orElseThrow(() -> new RuntimeException("Daily Order not found")); + return mapToDto(order); + } + + + /** + * GET BY CUSTOMER + DATE + */ + public CustomerDailyOrderResponseDto getDailyOrderByCustomerNo(String cusNo, LocalDate orderDate) { + CustomerDailyOrder order = dailyOrderRepository + .findByCdoCustomerNoAndCdoOrderDate(cusNo, orderDate) + .orElseThrow(() -> new RuntimeException("Order not found")); + return mapToDto(order); + } + + /** + * UPDATE BY CUSTOMER + DATE + */ + @Transactional + public CustomerDailyOrderResponseDto updateDailyOrderByCustomerNoAndOrderDate( + String customerNo, + LocalDate orderDate, + CustomerDailyOrderRequestDto dto + ) { + + // daily order 조회 + CustomerDailyOrder existing = dailyOrderRepository + .findByCdoCustomerNoAndCdoOrderDate(customerNo, orderDate) + .orElseThrow(() -> new RuntimeException("Daily Order not found")); + + // 기존 updateInternal 로 공통 업데이트 처리 + updateInternal(existing, dto); + + // 저장 + dailyOrderRepository.save(existing); + + return mapToDto(existing); + } + + + /** + * UPDATE + */ + @Transactional + public CustomerDailyOrderResponseDto updateDailyOrder(UUID uuid, CustomerDailyOrderRequestDto dto) { + CustomerDailyOrder existing = dailyOrderRepository.findByCdoUuid(uuid) + .orElseThrow(() -> new RuntimeException("Daily Order not found")); + + updateInternal(existing, dto); + + existing.setCdoUpdatedAt(LocalDateTime.now()); + existing.setCdoUpdatedBy(dto.getCdoLoginUser()); + + dailyOrderRepository.save(existing); + + return mapToDto(existing); + } + + + /** + * DELETE + */ + public void deleteDailyOrder(UUID uuid) { + CustomerDailyOrder existing = dailyOrderRepository.findByCdoUuid(uuid) + .orElseThrow(() -> new RuntimeException("Daily Order not found")); + dailyOrderRepository.delete(existing); + } + + + /** + * Internal Update Logic (CustomerService 동일 스타일) + */ + private void updateInternal(CustomerDailyOrder order, CustomerDailyOrderRequestDto dto) { + // driver id (employee id) + Long driverId = resolveDriverId(dto); + + // null 이 아닌 경우만 업데이트 + if (dto.getCdoOrderType() != null) order.setCdoOrderType(dto.getCdoOrderType()); + if (dto.getCdoRequestNote() != null) order.setCdoRequestNote(dto.getCdoRequestNote()); + if (dto.getCdoDriverId() != null || dto.getCdoExternalDriverId() != null || dto.getCdoDriverUuid() != null) order.setCdoDriverId(driverId); + if (dto.getCdoPaymentType() != null) order.setCdoPaymentType(dto.getCdoPaymentType()); + if (dto.getCdoCycle() != null) order.setCdoCycle(dto.getCdoCycle()); + if (dto.getCdoRate() != null) order.setCdoRate(dto.getCdoRate()); + if (dto.getCdoStatus() != null) order.setCdoStatus(dto.getCdoStatus()); + if (dto.getCdoVisitFlag() != null) order.setCdoVisitFlag(dto.getCdoVisitFlag()); + if (dto.getCdoEstimatedQty()!= null) order.setCdoEstimatedQty(dto.getCdoEstimatedQty()); + if (dto.getCdoQuantity() != null) order.setCdoQuantity(dto.getCdoQuantity()); + if (dto.getCdoSludge() != null) order.setCdoSludge(dto.getCdoSludge()); + if (dto.getCdoPayStatus() != null) order.setCdoPayStatus(dto.getCdoPayStatus()); + if (dto.getCdoPayAmount() != null) order.setCdoPayAmount(dto.getCdoPayAmount()); + if (dto.getCdoPayeeName() != null) order.setCdoPayeeName(dto.getCdoPayeeName()); + if (dto.getCdoPayeeSign() != null) order.setCdoPayeeSign(dto.getCdoPayeeSign()); + if (dto.getCdoPickupAt() != null) order.setCdoPickupAt(dto.getCdoPickupAt()); + if (dto.getCdoPickupNote() != null) order.setCdoPickupNote(dto.getCdoPickupNote()); + if (dto.getCdoPickupLat() != null) order.setCdoPickupLat(dto.getCdoPickupLat()); + if (dto.getCdoPickupLon() != null) order.setCdoPickupLon(dto.getCdoPickupLon()); + if (dto.getCdoPickupMin() != null) order.setCdoPickupMin(dto.getCdoPickupMin()); + } + + + /** + * ENTITY → RESPONSE DTO + */ + public CustomerDailyOrderResponseDto mapToDto(CustomerDailyOrder order) { + + if (order == null) return null; + + return CustomerDailyOrderResponseDto.builder() + .cdoUuid(order.getCdoUuid()) + .cdoOrderDate(order.getCdoOrderDate()) + .cdoOrderType(order.getCdoOrderType()) + .cdoRequestNote(order.getCdoRequestNote()) +// .cdoDriverId(order.getCdoDriverId()) + .cdoCustomerNo(order.getCdoCustomerNo()) + .cdoPaymentType(order.getCdoPaymentType()) + .cdoCycle(order.getCdoCycle()) + .cdoRate(order.getCdoRate()) + .cdoCreatedAt(order.getCdoCreatedAt()) + .cdoCreatedBy(order.getCdoCreatedBy()) + .cdoUpdatedAt(order.getCdoUpdatedAt()) + .cdoUpdatedBy(order.getCdoUpdatedBy()) + .cdoStatus(order.getCdoStatus()) + .cdoVisitFlag(order.getCdoVisitFlag()) + .cdoEstimatedQty(order.getCdoEstimatedQty()) + .cdoQuantity(order.getCdoQuantity()) + .cdoSludge(order.getCdoSludge()) + .cdoPayStatus(order.getCdoPayStatus()) + .cdoPayAmount(order.getCdoPayAmount()) + .cdoPayeeName(order.getCdoPayeeName()) + .cdoPayeeSign(order.getCdoPayeeSign()) + .cdoPickupAt(order.getCdoPickupAt()) + .cdoPickupNote(order.getCdoPickupNote()) + .cdoPickupLat(order.getCdoPickupLat()) + .cdoPickupLon(order.getCdoPickupLon()) + .cdoPickupMin(order.getCdoPickupMin()) + .build(); + } + + private Long resolveCustomerId(CustomerDailyOrderRequestDto dto) { + + // 1. ERP → customerId 직접 전달 + if (dto.getCdoCustomerId() != null) { + return dto.getCdoCustomerId(); + } + + // 2. ERP → customerUuid 전달 + if (dto.getCdoCustomerUuid() != null) { + return customerRepository.findByCusUuid(dto.getCdoCustomerUuid()) + .map(c -> c.getCusId()) + .orElseThrow(() -> new RuntimeException("Customer not found by UUID")); + } + + // 3. MIS → customerNo만 알 수 있을 때 + if (dto.getCdoCustomerNo() != null) { + return customerRepository.findByCusNo(dto.getCdoCustomerNo()) + .map(c -> c.getCusId()) + .orElseThrow(() -> new RuntimeException("Customer not found by customerNo")); + } + + return null; // 또는 throw + } + + + private Long resolveDriverId(CustomerDailyOrderRequestDto dto) { + + // 1. driver id + if (dto.getCdoDriverId() != null) { + return dto.getCdoDriverId(); + } + // 2. MIS -> externalId (문자열 email or id) + if (dto.getCdoExternalDriverId() != null) { + return hcmEmployeeClient.getEmpIdFromExternalId( dto.getCdoExternalDriverId() ); + } + + // 3. ERP 내부 요청 (driverUuid 방식) + if (dto.getCdoDriverUuid() != null) { + return hcmEmployeeClient.getEmpIdFromUuid(dto.getCdoDriverUuid()); + } + + return null; + } + +} diff --git a/src/main/java/com/goi/erp/service/CustomerService.java b/src/main/java/com/goi/erp/service/CustomerService.java new file mode 100644 index 0000000..06a7739 --- /dev/null +++ b/src/main/java/com/goi/erp/service/CustomerService.java @@ -0,0 +1,339 @@ +package com.goi.erp.service; + +import com.goi.erp.dto.CustomerRequestDto; +import com.goi.erp.dto.CustomerResponseDto; +import com.goi.erp.entity.Customer; +import com.goi.erp.entity.EntityChangeLog; +import com.goi.erp.repository.CustomerRepository; +import com.goi.erp.repository.EntityChangeLogRepository; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CustomerService { + + private final CustomerRepository customerRepository; + private final EntityChangeLogRepository entityChangeLogRepository; + + public CustomerResponseDto createCustomer(CustomerRequestDto dto) { + Customer customer = Customer.builder() + .cusUuid(UUID.randomUUID()) + .cusNo(dto.getCusNo()) + .cusName(dto.getCusName()) + .cusStatus(dto.getCusStatus()) + .cusAreaId(dto.getCusAreaId()) + .cusAddress1(dto.getCusAddress1()) + .cusAddress2(dto.getCusAddress2()) + .cusPostalCode(dto.getCusPostalCode()) + .cusCity(dto.getCusCity()) + .cusProvince(dto.getCusProvince()) + .cusGeoLat(dto.getCusGeoLat() != null ? BigDecimal.valueOf(dto.getCusGeoLat()) : null) + .cusGeoLon(dto.getCusGeoLon() != null ? BigDecimal.valueOf(dto.getCusGeoLon()) : null) + .cusEmail(dto.getCusEmail()) + .cusPhone(dto.getCusPhone()) + .cusPhoneExt(dto.getCusPhoneExt()) + .cusContractDate(dto.getCusContractDate()) + .cusContractedBy(dto.getCusContractedBy()) + .cusInstallDate(dto.getCusInstallDate()) + .cusFullCycle(dto.getCusFullCycle()) + .cusFullCycleFlag(dto.getCusFullCycleFlag()) + .cusFullCycleForced(dto.getCusFullCycleForced()) + .cusFullCycleForcedDate(dto.getCusFullCycleForcedDate()) + .cusSchedule(dto.getCusSchedule()) + .cusScheduledays(dto.getCusScheduledays()) + .cusLastPaidDate(dto.getCusLastPaidDate()) + .cusRate(dto.getCusRate() != null ? BigDecimal.valueOf(dto.getCusRate()) : null) + .cusPayMethod(dto.getCusPayMethod()) + .cusAccountNo(dto.getCusAccountNo()) + .cusIsccDate(dto.getCusIsccDate()) + .cusCorsiaDate(dto.getCusCorsiaDate()) + .cusHstNo(dto.getCusHstNo()) + .cusOpenTime(dto.getCusOpenTime()) + .cusComment(dto.getCusComment()) + .cusContactComment(dto.getCusContactComment()) + .cusInstallLocation(dto.getCusInstallLocation()) + .build(); + + customer = customerRepository.save(customer); + return mapToDto(customer); // 생성 시에는 여전히 엔티티 → DTO 필요 + } + + public Page getAllCustomers(int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page customers = customerRepository.findAll(pageable); + return customers.map(this::mapToDto); // 기존 mapToDto 사용 + } + + public CustomerResponseDto getCustomerByUuid(UUID uuid) { + Customer customer = customerRepository.findByCusUuid(uuid) + .orElseThrow(() -> new RuntimeException("Customer not found")); + return mapToDto(customer); + } + + public CustomerResponseDto updateCustomer(UUID uuid, CustomerRequestDto dto) { + Customer customer = customerRepository.findByCusUuid(uuid) + .orElseThrow(() -> new RuntimeException("Customer not found")); + return updateCustomerInternal(customer, dto); + } + + public void deleteCustomer(UUID uuid) { + Customer customer = customerRepository.findByCusUuid(uuid) + .orElseThrow(() -> new RuntimeException("Customer not found")); + customerRepository.delete(customer); + } + + private CustomerResponseDto updateCustomerInternal(Customer customer, CustomerRequestDto dto) { + + if (dto.getCusName() != null) customer.setCusName(dto.getCusName()); + if (dto.getCusStatus() != null) customer.setCusStatus(dto.getCusStatus()); + if (dto.getCusNo() != null) customer.setCusNo(dto.getCusNo()); + if (dto.getCusAreaId() != null) customer.setCusAreaId(dto.getCusAreaId()); + if (dto.getCusAddress1() != null) customer.setCusAddress1(dto.getCusAddress1()); + if (dto.getCusAddress2() != null) customer.setCusAddress2(dto.getCusAddress2()); + if (dto.getCusPostalCode() != null) customer.setCusPostalCode(dto.getCusPostalCode()); + if (dto.getCusCity() != null) customer.setCusCity(dto.getCusCity()); + if (dto.getCusProvince() != null) customer.setCusProvince(dto.getCusProvince()); + if (dto.getCusGeoLat() != null) customer.setCusGeoLat(BigDecimal.valueOf(dto.getCusGeoLat())); + if (dto.getCusGeoLon() != null) customer.setCusGeoLon(BigDecimal.valueOf(dto.getCusGeoLon())); + if (dto.getCusEmail() != null) customer.setCusEmail(dto.getCusEmail()); + if (dto.getCusPhone() != null) customer.setCusPhone(dto.getCusPhone()); + if (dto.getCusPhoneExt() != null) customer.setCusPhoneExt(dto.getCusPhoneExt()); + if (dto.getCusContractDate() != null) customer.setCusContractDate(dto.getCusContractDate()); + if (dto.getCusContractedBy() != null) customer.setCusContractedBy(dto.getCusContractedBy()); + if (dto.getCusInstallDate() != null) customer.setCusInstallDate(dto.getCusInstallDate()); + if (dto.getCusFullCycle() != null) customer.setCusFullCycle(dto.getCusFullCycle()); + if (dto.getCusFullCycleFlag() != null) customer.setCusFullCycleFlag(dto.getCusFullCycleFlag()); + if (dto.getCusFullCycleForced() != null) customer.setCusFullCycleForced(dto.getCusFullCycleForced()); + if (dto.getCusFullCycleForcedDate() != null) customer.setCusFullCycleForcedDate(dto.getCusFullCycleForcedDate()); + if (dto.getCusSchedule() != null) customer.setCusSchedule(dto.getCusSchedule()); + if (dto.getCusScheduledays() != null) customer.setCusScheduledays(dto.getCusScheduledays()); + if (dto.getCusLastPaidDate() != null) customer.setCusLastPaidDate(dto.getCusLastPaidDate()); + if (dto.getCusRate() != null) customer.setCusRate(BigDecimal.valueOf(dto.getCusRate())); + if (dto.getCusPayMethod() != null) customer.setCusPayMethod(dto.getCusPayMethod()); + if (dto.getCusIsccDate() != null) customer.setCusIsccDate(dto.getCusIsccDate()); + if (dto.getCusCorsiaDate() != null) customer.setCusCorsiaDate(dto.getCusCorsiaDate()); + if (dto.getCusHstNo() != null) customer.setCusHstNo(dto.getCusHstNo()); + if (dto.getCusTerminatedDate() != null) customer.setCusTerminatedDate(dto.getCusTerminatedDate()); + if (dto.getCusTerminationReason() != null) customer.setCusTerminationReason(dto.getCusTerminationReason()); + if (dto.getCusLastPickupDate() != null) customer.setCusLastPickupDate(dto.getCusLastPickupDate()); + if (dto.getCusLastPickupQty() != null) customer.setCusLastPickupQty(dto.getCusLastPickupQty()); + if (dto.getCusLastPickupLat() != null) customer.setCusLastPickupLat(BigDecimal.valueOf(dto.getCusLastPickupLat())); + if (dto.getCusLastPickupLon() != null) customer.setCusLastPickupLon(BigDecimal.valueOf(dto.getCusLastPickupLon())); + if (dto.getCusOpenTime() != null) customer.setCusOpenTime(dto.getCusOpenTime()); + if (dto.getCusComment() != null) customer.setCusComment(dto.getCusComment()); + if (dto.getCusContactComment() != null) customer.setCusContactComment(dto.getCusContactComment()); + if (dto.getCusInstallLocation() != null) customer.setCusInstallLocation(dto.getCusInstallLocation()); + + customerRepository.save(customer); + return mapToDto(customer); + } + + + public CustomerResponseDto mapToDto(Customer customer) { + if (customer == null) return null; + + CustomerResponseDto dto = new CustomerResponseDto(); + dto.setCusUuid(customer.getCusUuid()); + dto.setCusNo(customer.getCusNo()); + dto.setCusName(customer.getCusName()); + dto.setCusStatus(customer.getCusStatus()); + dto.setCusAddress1(customer.getCusAddress1()); + dto.setCusAddress2(customer.getCusAddress2()); + dto.setCusCity(customer.getCusCity()); + dto.setCusProvince(customer.getCusProvince()); + dto.setCusGeoLat(customer.getCusGeoLat()); + dto.setCusGeoLon(customer.getCusGeoLon()); + dto.setCusEmail(customer.getCusEmail()); + dto.setCusPhone(customer.getCusPhone()); + dto.setCusPhoneExt(customer.getCusPhoneExt()); + dto.setCusContractDate(customer.getCusContractDate()); + dto.setCusContractedBy(customer.getCusContractedBy()); + dto.setCusInstallDate(customer.getCusInstallDate()); + dto.setCusInstallLocation(customer.getCusInstallLocation()); + dto.setCusLastPickupDate(customer.getCusLastPickupDate()); + dto.setCusLastPickupQty(customer.getCusLastPickupQty()); + dto.setCusLastPickupLat(customer.getCusLastPickupLat()); + dto.setCusLastPickupLon(customer.getCusLastPickupLon()); + dto.setCusFullCycle(customer.getCusFullCycle()); + dto.setCusFullCycleFlag(customer.getCusFullCycleFlag()); + dto.setCusFullCycleForced(customer.getCusFullCycleForced()); + dto.setCusFullCycleForcedDate(customer.getCusFullCycleForcedDate()); + dto.setCusSchedule(customer.getCusSchedule()); + dto.setCusScheduledays(customer.getCusScheduledays()); + dto.setCusLastPaidDate(customer.getCusLastPaidDate()); + dto.setCusRate(customer.getCusRate()); + dto.setCusPayMethod(customer.getCusPayMethod()); + dto.setCusAccountNo(customer.getCusAccountNo()); + dto.setCusIsccDate(customer.getCusIsccDate()); + dto.setCusCorsiaDate(customer.getCusCorsiaDate()); + dto.setCusHstNo(customer.getCusHstNo()); + dto.setCusTerminatedDate(customer.getCusTerminatedDate()); + dto.setCusTerminationReason(customer.getCusTerminationReason()); + dto.setCusLastPickupMin(customer.getCusLastPickupMin()); + dto.setCusOpenTime(customer.getCusOpenTime()); + dto.setCusComment(customer.getCusComment()); + dto.setCusContactComment(customer.getCusContactComment()); + dto.setCusInstallLocation(customer.getCusInstallLocation()); + + return dto; + } + + + // from MIS + public CustomerResponseDto getCustomerByNo(String cusNo) { + Customer customer = customerRepository.findByCusNo(cusNo) + .orElseThrow(() -> new RuntimeException("Customer not found")); + return mapToDto(customer); + } + + @Transactional + public CustomerResponseDto updateCustomerByNo(String cusNo, CustomerRequestDto newCustomer) { + + Customer oldCustomer = customerRepository.findByCusNo(cusNo) + .orElseThrow(() -> new RuntimeException("Customer not found")); + + // 1. OLD VALUE 백업 (deep copy) + Customer beforeUpdate = new Customer(); + BeanUtils.copyProperties(oldCustomer, beforeUpdate); + + // 2. 주소/우편번호 변경 시 geo 초기화 + if ((newCustomer.getCusAddress1() != null && !newCustomer.getCusAddress1().equals(oldCustomer.getCusAddress1())) || + (newCustomer.getCusPostalCode() != null && !newCustomer.getCusPostalCode().equals(oldCustomer.getCusPostalCode()))) { + oldCustomer.setCusGeoLat(null); + oldCustomer.setCusGeoLon(null); + } + + // 3. 실제 업데이트 적용 + CustomerResponseDto response = updateCustomerInternal(oldCustomer, newCustomer); + + // 4. 변경 비교 (old vs new) + String misLoginUser = newCustomer.getCusLoginUser(); + compareAndLogChanges(beforeUpdate, oldCustomer, misLoginUser); + + return response; + } + + + // set change log + private void compareAndLogChanges(Customer oldData, Customer newData, String changedBy) { + + // 필드 → DB 컬럼 매핑 + Map fieldToColumn = Map.ofEntries( + Map.entry("cusName", "cus_name"), + Map.entry("cusEmail", "cus_email"), + Map.entry("cusPhone", "cus_phone"), + Map.entry("cusGeoLat", "cus_geo_lat"), + Map.entry("cusGeoLon", "cus_geo_lon"), + Map.entry("cusStatus", "cus_status"), + Map.entry("cusAddress1", "cus_address1"), + Map.entry("cusAddress2", "cus_address2"), + Map.entry("cusPostalCode", "cus_postal_code"), + Map.entry("cusCity", "cus_city"), + Map.entry("cusProvince", "cus_province"), + Map.entry("cusPhoneExt", "cus_phone_ext"), + Map.entry("cusContractDate", "cus_contract_date"), + Map.entry("cusContractedBy", "cus_contracted_by"), + Map.entry("cusInstallDate", "cus_install_date"), + Map.entry("cusFullCycle", "cus_full_cycle"), + Map.entry("cusFullCycleFlag", "cus_full_cycle_flag"), + Map.entry("cusFullCycleForced", "cus_full_cycle_forced"), + Map.entry("cusFullCycleForcedDate", "cus_full_cycle_forced_date"), + Map.entry("cusSchedule", "cus_schedule"), + Map.entry("cusScheduledays", "cus_scheduledays"), + Map.entry("cusLastPaidDate", "cus_last_paid_date"), + Map.entry("cusRate", "cus_rate"), + Map.entry("cusPayMethod", "cus_pay_method"), + Map.entry("cusAccountNo", "cus_account_no"), + Map.entry("cusIsccDate", "cus_iscc_date"), + Map.entry("cusCorsiaDate", "cus_corsia_date"), + Map.entry("cusHstNo", "cus_hst_no"), + Map.entry("cusOpenTime", "cus_open_time"), + Map.entry("cusComment", "cus_comment"), + Map.entry("cusContactComment", "cus_contact_comment"), + Map.entry("cusInstallLocation", "cus_install_location") + ); + + Class clazz = Customer.class; + + for (var entry : fieldToColumn.entrySet()) { + String fieldName = entry.getKey(); + String columnName = entry.getValue(); + + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + + Object oldVal = field.get(oldData); + Object newVal = field.get(newData); + + if (valuesAreDifferent(oldVal, newVal)) { + entityChangeLogRepository.save( + EntityChangeLog.builder() + .eclEntityType("Customer") + .eclEntityId(newData.getCusId()) + .eclFieldName(fieldName) + .eclColumnName(columnName) + .eclOldValue(oldVal == null ? null : oldVal.toString()) + .eclNewValue(newVal == null ? null : newVal.toString()) + .eclEffectiveDate(LocalDate.now()) + .eclChangedBy(changedBy) + .eclChangedAt(LocalDateTime.now()) + .build() + ); + } + + } catch (Exception e) { + throw new RuntimeException("Failed to compare field: " + fieldName, e); + } + } + } + + private boolean valuesAreDifferent(Object oldVal, Object newVal) { + + // 둘 다 null → 변경 없음 + if (oldVal == null && newVal == null) return false; + + // 한쪽만 null → 변경됨 + if (oldVal == null || newVal == null) return true; + + // BigDecimal (numeric 비교) + if (oldVal instanceof BigDecimal oldNum && newVal instanceof BigDecimal newNum) { + return oldNum.compareTo(newNum) != 0; // scale 무시 비교 + } + + // LocalDate + if (oldVal instanceof LocalDate oldDate && newVal instanceof LocalDate newDate) { + return !oldDate.isEqual(newDate); + } + + // LocalDateTime + if (oldVal instanceof LocalDateTime oldDt && newVal instanceof LocalDateTime newDt) { + return !oldDt.equals(newDt); + } + + // Boolean + if (oldVal instanceof Boolean && newVal instanceof Boolean) { + return !oldVal.equals(newVal); + } + + // 그 외 (String 포함) 기본 equals 비교 + return !oldVal.equals(newVal); + } + + +} diff --git a/src/main/java/com/goi/erp/service/HcmEmployeeClient.java b/src/main/java/com/goi/erp/service/HcmEmployeeClient.java new file mode 100644 index 0000000..d07adb7 --- /dev/null +++ b/src/main/java/com/goi/erp/service/HcmEmployeeClient.java @@ -0,0 +1,124 @@ +package com.goi.erp.service; + +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.goi.erp.token.PermissionAuthenticationToken; + +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class HcmEmployeeClient { + + private final RestTemplate restTemplate; + @Value("${hcm.api.base-url}") + private String hcmBaseUrl; + + public Long getEmpIdFromExternalId(String externalId) { + + String url = hcmBaseUrl + "/employee/external" + "?solutionType=MIS&externalId=" + externalId; + + try { + // set token in header + String jwt = getCurrentJwt(); + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwt); + HttpEntity entity = new HttpEntity<>(headers); + + // GET + ResponseEntity> response = + restTemplate.exchange( + url, + HttpMethod.GET, + entity, + new ParameterizedTypeReference>() {} + ); + + Map body = response.getBody(); + //System.out.println("RESPONSE ➜ " + body); + + if (body != null && body.get("eexEmpId") != null) { + + Object raw = body.get("eexEmpId"); + + if (raw instanceof Number) { + return ((Number) raw).longValue(); // 🔥 모든 숫자를 Long 변환 + } + + // 예상 밖 타입일 경우 + } + + return null; + + } catch (Exception e) { + // 필요하면 logging + System.out.println("externalId lookup error: " + e.getMessage()); + return null; + } + } + + public Long getEmpIdFromUuid(UUID uuid) { + + String url = hcmBaseUrl + "/employee/" + uuid; + + try { + // set token in header + String jwt = getCurrentJwt(); + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwt); + HttpEntity entity = new HttpEntity<>(headers); + + // GET + ResponseEntity> response = + restTemplate.exchange( + url, + HttpMethod.GET, + entity, + new ParameterizedTypeReference>() {} + ); + + Map body = response.getBody(); + //System.out.println("RESPONSE(UUID) ➜ " + body); + + if (body != null && body.get("empId") != null) { + + Object raw = body.get("empId"); + + if (raw instanceof Number) { + return ((Number) raw).longValue(); // 🔥 모든 숫자를 Long 변환 + } + + // 예상 밖 타입일 경우 + } + + return null; + + } catch (Exception e) { + // 필요하면 로깅 + System.out.println("UUID lookup error: " + e.getMessage()); + return null; + } + } + + private String getCurrentJwt() { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof PermissionAuthenticationToken token) { + return token.getJwt(); + } + return null; + } + + +} + diff --git a/src/main/java/com/goi/erp/token/ApplicationAuditAware.java b/src/main/java/com/goi/erp/token/ApplicationAuditAware.java new file mode 100644 index 0000000..ee72a5e --- /dev/null +++ b/src/main/java/com/goi/erp/token/ApplicationAuditAware.java @@ -0,0 +1,90 @@ +package com.goi.erp.token; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.data.domain.AuditorAware; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import java.security.Key; + +/** + * auth-service에서 발급한 JWT 토큰 기반으로 현재 사용자(empId)를 가져오는 AuditorAware 구현체 + * + * - JPA auditing에서 사용자가 누군지 기록할 때 사용 + * - SecurityContextHolder 없이도 동작 가능 + * - HttpServletRequest에서 Authorization 헤더를 읽어 토큰 파싱 + */ +public class ApplicationAuditAware implements AuditorAware { + + private final String jwtSecret; + + public ApplicationAuditAware(String jwtSecret) { + this.jwtSecret = jwtSecret; + } + + /** + * 현재 요청을 수행하는 사용자의 empId 반환 + * @return Optional - empId가 없거나 토큰이 유효하지 않으면 Optional.empty() + */ + @Override + public Optional getCurrentAuditor() { + HttpServletRequest request = getCurrentHttpRequest(); + if (request == null) { + return Optional.empty(); + } + + String token = resolveToken(request); + if (token == null) { + return Optional.empty(); + } + + try { + // JWT 파싱 + byte[] keyBytes = Decoders.BASE64.decode(jwtSecret); + Key key = Keys.hmacShaKeyFor(keyBytes); + + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + // 토큰에 loginId 클레임이 있어야 함 + String loginId = claims.get("loginId", String.class); + return Optional.ofNullable(loginId); + } catch (Exception e) { + // 토큰 파싱/검증 실패 시 Optional.empty() 반환 + e.printStackTrace(); // 🔥 예외 확인 + System.out.println("JWT Error: " + e.getMessage()); + return Optional.empty(); + } + } + + /** + * 현재 스레드의 HttpServletRequest 가져오기 + * @return HttpServletRequest 또는 null + */ + private HttpServletRequest getCurrentHttpRequest() { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) return null; + return attrs.getRequest(); + } + + /** + * HttpServletRequest에서 Authorization 헤더의 Bearer 토큰 추출 + * @param request 현재 HttpServletRequest + * @return JWT 문자열 또는 null + */ + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/goi/erp/token/JwtService.java b/src/main/java/com/goi/erp/token/JwtService.java new file mode 100644 index 0000000..37e9026 --- /dev/null +++ b/src/main/java/com/goi/erp/token/JwtService.java @@ -0,0 +1,149 @@ +package com.goi.erp.token; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.goi.erp.common.permission.PermissionParser; +import com.goi.erp.common.permission.PermissionSet; + +import java.security.Key; +import java.util.List; +import java.util.function.Function; + +/** + * - DB 접근 없음 + * - JWT에 포함된 empUuid, 이름, roles, permissions 추출 + * - 토큰 유효기간 체크 가능 + */ +@Service +public class JwtService { + + @Value("${application.security.jwt.secret-key}") + private String secretKey; + + @Value("${application.security.jwt.expiration}") + private long jwtExpiration; + + /** + * empUuid(sub) 추출 + */ + public String extractEmpUuid(String token) { + return extractClaim(token, Claims::getSubject); + } + + /** + * loginId 추출 + */ + public String extractLoginId(String token) { + return extractClaim(token, claims -> claims.get("loginId", String.class)); + } + + /** + * firstName 추출 + */ + public String extractFirstName(String token) { + return extractClaim(token, claims -> claims.get("firstName", String.class)); + } + + /** + * lastName 추출 + */ + public String extractLastName(String token) { + return extractClaim(token, claims -> claims.get("lastName", String.class)); + } + + /** + * roles 리스트 추출 + */ + @SuppressWarnings("unchecked") + public List extractRoles(String token) { + return extractClaim(token, claims -> (List) claims.get("roles")); + } + + /** + * permissions 리스트 추출 + */ + @SuppressWarnings("unchecked") + public List extractPermissions(String token) { + return extractClaim(token, claims -> (List) claims.get("permissions")); + } + + /** + * 토큰 만료 여부 확인 + */ + public boolean isTokenExpired(String token) { + return extractClaim(token, Claims::getExpiration).before(new java.util.Date()); + } + + /** + * 토큰 유효성 검사 (만료 체크) + */ + public boolean isTokenValid(String token) { + return !isTokenExpired(token); + } + + /** + * JWT에서 Claims 추출 + */ + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + /** + * JWT 전체 Claims 추출 + */ + private Claims extractAllClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /** + * Permission Set 변환 + */ + @SuppressWarnings("unchecked") + public PermissionSet getPermissions(String token) { + Claims claims = extractAllClaims(token); + List permissions = claims.get("permissions", List.class); + return PermissionParser.parse(permissions); + } + + private Key getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); // auth-service와 동일한 Base64 secret + return Keys.hmacShaKeyFor(keyBytes); + } + + public static void main(String[] args) { + JwtService jwtService = new JwtService(); + jwtService.secretKey = "D0HaHnTPKLkUO9ULL1Ulm6XDZjhzuFtvTCcxTxSoCS8="; + + String token = "eyJhbGciOiJIUzI1NiJ9.eyJmaXJzdE5hbWUiOiJNSVMiLCJsYXN0TmFtZSI6IkNTIiwibG9naW5JZCI6ImNzX21pcyIsInBlcm1pc3Npb25zIjpbIkg6UjpTIiwiQzpDOkEiLCJDOlI6QSIsIkM6VTpBIiwiQzpEOkEiXSwicm9sZXMiOlsiQ1MgU3RhZmYiXSwic3ViIjoiMWU3NTU4YzYtOTFhZC00ZDcxLTg3ZTUtZGJjZmZiYjk5Zjg1IiwiaWF0IjoxNzY0MzQ3Nzg1LCJleHAiOjIwNzk3MDc3ODV9.lL-ZHEpiribxIrNmeYp6LAeU11z-KuRbgELkWjHCCSc"; + + // user 정보 + Claims claims = jwtService.extractAllClaims(token); + + System.out.println("Subject (emp_uuid): " + claims.getSubject()); + System.out.println("Roles: " + claims.get("roles")); + System.out.println("Roles: " + claims.get("permissions")); + System.out.println("IssuedAt: " + claims.getIssuedAt()); + System.out.println("Expiration: " + claims.getExpiration()); + System.out.println("FirstName: " + claims.get("firstName", String.class)); + System.out.println("LastName: " + claims.get("lastName", String.class)); + + // 모든 Claims 확인 +// Claims claims = Jwts.parserBuilder() +// .setSigningKey(Keys.hmacShaKeyFor("".getBytes())) +// .build() +// .parseClaimsJws(token) +// .getBody(); + + System.out.println("Claims: " + claims); + } +} diff --git a/src/main/java/com/goi/erp/token/PermissionAuthenticationToken.java b/src/main/java/com/goi/erp/token/PermissionAuthenticationToken.java new file mode 100644 index 0000000..75b3b6d --- /dev/null +++ b/src/main/java/com/goi/erp/token/PermissionAuthenticationToken.java @@ -0,0 +1,40 @@ +package com.goi.erp.token; + +import java.util.Collection; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import com.goi.erp.common.permission.PermissionSet; + +public class PermissionAuthenticationToken extends UsernamePasswordAuthenticationToken { + private static final long serialVersionUID = 1L; + + private final PermissionSet permissionSet; + private final String jwt; // ★ JWT 저장 + + /** + * @param principal 로그인 ID 또는 emp_uuid + * @param jwt 실제 JWT 토큰 문자열 + * @param permissionSet 권한 정보 + * @param authorities Spring Security Authority + */ + public PermissionAuthenticationToken( + String principal, + String jwt, + PermissionSet permissionSet, + Collection authorities + ) { + super(principal, jwt, authorities); // credentials 에 jwt 넣어줌 + this.permissionSet = permissionSet; + this.jwt = jwt; // ★ 여기 저장 + } + + public PermissionSet getPermissionSet() { + return permissionSet; + } + + public String getJwt() { + return jwt; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..57d7565 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,39 @@ +spring: + datasource: + url: jdbc:postgresql://${DB_HOST}:${DB_PORT:5432}/${DB_NAME:goi} + username: ${DB_USER} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: true + database: postgresql + database-platform: org.hibernate.dialect.PostgreSQLDialect + autoconfigure: + exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration +application: + security: + jwt: + secret-key: ${SECRET_KEY} + expiration: 86400000 # a day + refresh-token: + expiration: 604800000 # 7 days +pagination: + default-page: 0 + default-size: 20 + max-size: 100 +server: + port: 8090 + servlet: + context-path: /sys-rest-api + +# ================================ +# ADD THIS +# ================================ +hcm: + api: + base-url: http://localhost:8081/hcm-rest-api \ No newline at end of file diff --git a/src/test/java/com/goi/security/SecurityApplicationTests.java b/src/test/java/com/goi/security/SecurityApplicationTests.java new file mode 100644 index 0000000..2e9b375 --- /dev/null +++ b/src/test/java/com/goi/security/SecurityApplicationTests.java @@ -0,0 +1,13 @@ +package com.goi.security; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SecurityApplicationTests { + + @Test + void contextLoads() { + } + +}