Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb9606602 | |||
| 227d4d8abd | |||
| 4395757d98 | |||
| 778d579ada | |||
| 3e403788e5 | |||
| 6c5ba6765d | |||
| a97378b84d | |||
| 9c01db9d00 | |||
| 08bd705dec | |||
| 99f765507b | |||
| 7d6aabe122 | |||
| f08da86eb3 | |||
| 2a2505d5f7 | |||
| cad574324e | |||
| 4b4ef4196e | |||
| 5402a5354a | |||
| c5b23c3873 | |||
| 83d267faed | |||
| b02e598d62 | |||
| 4f9fbefbcc | |||
| c403118289 | |||
| a91b56ed11 | |||
| 8d232734b1 | |||
| 06c18e437b | |||
| 97b9d2161a | |||
| 0c3b63a5b1 | |||
| 582a3e0f69 | |||
| c1ae12e1ca | |||
| 77aea79363 | |||
| aa5e769148 | |||
| ee8e9e3529 | |||
| 82948a13ce | |||
| f79e528fec | |||
| 6f776ed72a | |||
| e242d3ed06 | |||
| d7abe2c2e5 | |||
| 84d92fced9 | |||
| 53468c3b83 | |||
| 198f6fc82a | |||
| 21c500610c | |||
| 687d4798d4 | |||
| 5adfd42751 | |||
| 6694715195 | |||
| fc96c13df2 | |||
| 7265ad6589 | |||
| 02f9d41334 | |||
| fe4b176512 | |||
| 17c27e4398 | |||
| 10ff5d6f2f | |||
| f671f491b7 | |||
| 3d68589ae7 | |||
| 7268aef211 | |||
| 85b2f01ce0 | |||
| 0f255d7337 | |||
| a8323e78f5 | |||
| fa8fce4f2d | |||
| 4e7044b9fe | |||
| bddd7e3f85 | |||
| 71f4cb6f79 |
@@ -0,0 +1,159 @@
|
|||||||
|
pipeline {
|
||||||
|
agent none
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
skipDefaultCheckout() // stage별 agent의 자동 SCM checkout 방지 (Deploy 노드는 git 접근 불필요, unstash로만 WAR 수신)
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||||
|
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Build (djb-vm)') {
|
||||||
|
agent { label 'djb-vm' }
|
||||||
|
environment {
|
||||||
|
JAVA_HOME = '/apps/opts/jdk8'
|
||||||
|
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||||
|
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||||
|
NODE_HOME = '/apps/opts/node-v24'
|
||||||
|
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
|
||||||
|
}
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps { checkout scm }
|
||||||
|
}
|
||||||
|
stage('Checkout dependencies') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
cd "$WORKSPACE/.."
|
||||||
|
if [ ! -d elink-portal-common/.git ]; then
|
||||||
|
rm -rf elink-portal-common
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git elink-portal-common
|
||||||
|
else
|
||||||
|
git -C elink-portal-common fetch --depth=1 origin master
|
||||||
|
git -C elink-portal-common reset --hard origin/master
|
||||||
|
git -C elink-portal-common clean -fdx
|
||||||
|
fi
|
||||||
|
mkdir -p eapim-online
|
||||||
|
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
|
||||||
|
rm -rf eapim-online/elink-online-core-jpa
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git eapim-online/elink-online-core-jpa
|
||||||
|
else
|
||||||
|
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
||||||
|
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
||||||
|
git -C eapim-online/elink-online-core-jpa clean -fdx
|
||||||
|
fi
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Verify toolchain') {
|
||||||
|
steps { sh 'set -eu; java -version; gradle --version' }
|
||||||
|
}
|
||||||
|
stage('Test') {
|
||||||
|
steps { sh 'gradle clean test --no-daemon -Pprofile=weblogic' }
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||||
|
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Build WAR') {
|
||||||
|
steps { sh 'gradle build -x test --no-daemon -Pprofile=weblogic' }
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
cd build/libs
|
||||||
|
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
||||||
|
'''
|
||||||
|
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
|
||||||
|
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy (weblogic)') {
|
||||||
|
agent { label 'weblogic' }
|
||||||
|
environment {
|
||||||
|
JENKINS_NODE_COOKIE = 'dontKillMe' // background weblogic를 빌드 종료 시 죽이지 않게
|
||||||
|
WL_HOME = '/app/eapim/devportal'
|
||||||
|
WL_DEPLOY_DIR = '/app/eapim/devportal'
|
||||||
|
WL_WAR_NAME = 'eapim-portal.war'
|
||||||
|
WL_HTTP_PORT = '39130'
|
||||||
|
WL_NOHUP = '/logs/weblogic/domains/eapimDomain/nohup.devSvr11.out'
|
||||||
|
}
|
||||||
|
stages {
|
||||||
|
stage('Stop WebLogic') {
|
||||||
|
steps {
|
||||||
|
sh '"$WL_HOME/stopDev11.sh"' // 동기: 완전 종료까지 블록, 미기동 시에도 안전
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Deploy WAR') {
|
||||||
|
steps {
|
||||||
|
unstash 'war'
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
cp build/libs/eapim-portal.war "$WL_DEPLOY_DIR/$WL_WAR_NAME"
|
||||||
|
ls -la "$WL_DEPLOY_DIR"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Start WebLogic and readiness') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
"$WL_HOME/startDev11.sh" # nohup & 로 백그라운드 기동, 즉시 리턴
|
||||||
|
|
||||||
|
DEADLINE=$(($(date +%s) + 300))
|
||||||
|
STATUS=000
|
||||||
|
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||||
|
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||||
|
"http://localhost:$WL_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
|
||||||
|
[ "$STATUS" = "200" ] && { echo "Readiness OK (/health/ready)"; break; }
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$STATUS" != "200" ]; then
|
||||||
|
echo "Readiness failed within 300s, last HTTP=$STATUS"
|
||||||
|
[ -f "$WL_NOHUP" ] && tail -120 "$WL_NOHUP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
node('weblogic') {
|
||||||
|
sh '''
|
||||||
|
set +e
|
||||||
|
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
||||||
|
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
node('weblogic') {
|
||||||
|
sh '''
|
||||||
|
set +e
|
||||||
|
payload=$(printf '{"text":"[%s #%s](%s) FAILURE"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
||||||
|
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
pipeline {
|
||||||
|
agent { label 'djb-vm' }
|
||||||
|
|
||||||
|
triggers {
|
||||||
|
pollSCM('H/10 * * * *')
|
||||||
|
}
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
JAVA_HOME = '/apps/opts/jdk8'
|
||||||
|
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
|
||||||
|
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||||
|
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||||
|
NODE_HOME = '/apps/opts/node-v24'
|
||||||
|
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
|
||||||
|
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Checkout dependencies') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
cd "$WORKSPACE/.."
|
||||||
|
|
||||||
|
if [ ! -d elink-portal-common/.git ]; then
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
|
||||||
|
elink-portal-common
|
||||||
|
else
|
||||||
|
git -C elink-portal-common fetch --depth=1 origin master
|
||||||
|
git -C elink-portal-common reset --hard origin/master
|
||||||
|
git -C elink-portal-common clean -fdx
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p eapim-online
|
||||||
|
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
|
||||||
|
eapim-online/elink-online-core-jpa
|
||||||
|
else
|
||||||
|
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
||||||
|
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
||||||
|
git -C eapim-online/elink-online-core-jpa clean -fdx
|
||||||
|
fi
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Verify toolchain') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
java -version
|
||||||
|
gradle --version
|
||||||
|
node --version || true
|
||||||
|
npm --version || true
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Test') {
|
||||||
|
steps {
|
||||||
|
sh 'gradle clean test --no-daemon'
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||||
|
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build WAR') {
|
||||||
|
steps {
|
||||||
|
sh 'gradle build -x test --no-daemon'
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
cd build/libs
|
||||||
|
for f in eapim-portal.war eapim-portal-boot.war; do
|
||||||
|
sha1sum "$f" > "$f.sha1"
|
||||||
|
sha256sum "$f" > "$f.sha256"
|
||||||
|
md5sum "$f" > "$f.md5"
|
||||||
|
done
|
||||||
|
'''
|
||||||
|
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
|
|
||||||
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
|
|
||||||
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
|
|
||||||
|
|
||||||
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
|
|
||||||
|
|
||||||
|
|
||||||
echo "========================"
|
|
||||||
java -version
|
|
||||||
javac -version
|
|
||||||
echo "========================"
|
|
||||||
gradle --version
|
|
||||||
echo "========================"
|
|
||||||
|
|
||||||
gradle clean build -x test --no-daemon
|
|
||||||
+13
-8
@@ -3,6 +3,7 @@ plugins {
|
|||||||
id 'war'
|
id 'war'
|
||||||
id 'eclipse'
|
id 'eclipse'
|
||||||
id 'idea'
|
id 'idea'
|
||||||
|
id 'org.cyclonedx.bom' version '3.2.4'
|
||||||
id 'org.springframework.boot' version '2.7.18'
|
id 'org.springframework.boot' version '2.7.18'
|
||||||
id 'io.spring.dependency-management' version '1.1.3'
|
id 'io.spring.dependency-management' version '1.1.3'
|
||||||
}
|
}
|
||||||
@@ -29,9 +30,12 @@ dependencies {
|
|||||||
|
|
||||||
implementation 'com.eactive.elink.common:elink-common-data:4.5.5'
|
implementation 'com.eactive.elink.common:elink-common-data:4.5.5'
|
||||||
|
|
||||||
// implementation 'io.swagger.core.v3:swagger-core:2.2.25'
|
// OpenAPI 3.0/3.1 spec 지원 (Nexus/Maven 해석). 기존 libs/swagger fileTree 대체.
|
||||||
// implementation 'io.swagger.parser.v3:swagger-parser:2.1.23'
|
// swagger 최신 릴리스(2.2.52/2.1.45)도 SpecVersion 은 V30/V31 까지만 — OpenAPI 3.2 는
|
||||||
implementation fileTree(dir: 'libs/swagger', include: ['*.jar'])
|
// 아직 swagger-core/parser 어느 버전도 미지원. 지원 추가 시 버전만 상향하면 됨. JDK8 호환.
|
||||||
|
implementation 'io.swagger.core.v3:swagger-core:2.2.52' // io.swagger.v3.oas.models.*, io.swagger.v3.core.util.Json
|
||||||
|
implementation 'io.swagger.parser.v3:swagger-parser:2.1.45' // io.swagger.parser.OpenAPIParser, io.swagger.v3.parser.*
|
||||||
|
implementation 'io.swagger:swagger-annotations:1.6.14' // io.swagger.annotations.ApiModelProperty (CommissionSearch)
|
||||||
|
|
||||||
implementation project(':elink-online-core-jpa')
|
implementation project(':elink-online-core-jpa')
|
||||||
implementation project(':elink-portal-common')
|
implementation project(':elink-portal-common')
|
||||||
@@ -150,11 +154,11 @@ compileJava {
|
|||||||
}
|
}
|
||||||
|
|
||||||
processResources {
|
processResources {
|
||||||
exclude { details ->
|
// exclude { details ->
|
||||||
details.file.name.startsWith('application-') &&
|
// details.file.name.startsWith('application-') &&
|
||||||
details.file.name.endsWith('.yml') &&
|
// details.file.name.endsWith('.yml') &&
|
||||||
!(details.file.name in ['application-stage.yml', 'application-prod.yml'])
|
// !(details.file.name in ['application-stage.yml', 'application-prod.yml'])
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
test {
|
test {
|
||||||
@@ -170,6 +174,7 @@ test {
|
|||||||
|
|
||||||
bootWar {
|
bootWar {
|
||||||
archiveFileName = "eapim-portal-boot.war"
|
archiveFileName = "eapim-portal-boot.war"
|
||||||
|
mainClass = 'com.eactive.apim.portal.PortalApplication'
|
||||||
}
|
}
|
||||||
|
|
||||||
war {
|
war {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
./gradlew bootWar
|
|
||||||
docker build -t eactive-portal .
|
|
||||||
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Check if the war file is provided
|
|
||||||
if [ -z "$1" ]; then
|
|
||||||
echo "Usage: $0 /path/to/DEVPortal.war"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Variables
|
|
||||||
WAR_FILE="$1"
|
|
||||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
|
||||||
DEST_DIR="/app/apim/deploy/DVPWeb"
|
|
||||||
BACKUP_DIR="/app/apim/backup"
|
|
||||||
|
|
||||||
# Create backup directory if not exists
|
|
||||||
mkdir -p $BACKUP_DIR
|
|
||||||
|
|
||||||
# Backup the existing directory
|
|
||||||
if [ -d "$DEST_DIR" ]; then
|
|
||||||
echo "Backing up existing directory..."
|
|
||||||
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create destination directory if not exists
|
|
||||||
mkdir -p $DEST_DIR
|
|
||||||
|
|
||||||
# Unzip the file to the dedicated location
|
|
||||||
echo "Extracting WAR file to $DEST_DIR..."
|
|
||||||
unzip $WAR_FILE -d $DEST_DIR
|
|
||||||
|
|
||||||
echo "deploy successful"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Check if the war file is provided
|
|
||||||
if [ -z "$1" ]; then
|
|
||||||
echo "Usage: $0 /path/to/DEVPortal.war"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Variables
|
|
||||||
WAR_FILE="$1"
|
|
||||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
|
||||||
DEST_DIR="/app/apim/deploy/DVPWeb"
|
|
||||||
STATIC_DIR="/app/apim/deploy/static"
|
|
||||||
BACKUP_DIR="/app/apim/backup"
|
|
||||||
|
|
||||||
# Create backup directory if not exists
|
|
||||||
mkdir -p $BACKUP_DIR
|
|
||||||
|
|
||||||
# Backup the existing directory
|
|
||||||
if [ -d "$STATIC_DIR" ]; then
|
|
||||||
echo "Backing up existing static directory..."
|
|
||||||
mv $STATIC_DIR "${BACKUP_DIR}/dvp_static_${TIMESTAMP}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$DEST_DIR" ]; then
|
|
||||||
echo "Backing up existing directory..."
|
|
||||||
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# Create destination directory if not exists
|
|
||||||
mkdir -p $DEST_DIR
|
|
||||||
mkdir -p $STATIC_DIR
|
|
||||||
|
|
||||||
# unzip the file to the dedicated location
|
|
||||||
echo "Extracting WAR file to $DEST_DIR..."
|
|
||||||
unzip $WAR_FILE -d $DEST_DIR
|
|
||||||
mv $DEST_DIR/static/* $STATIC_DIR/
|
|
||||||
|
|
||||||
echo "deploy successful"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
|
|
||||||
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
|
|
||||||
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
|
|
||||||
|
|
||||||
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
|
|
||||||
|
|
||||||
|
|
||||||
# gradle 명령을 대체합니다.
|
|
||||||
command gradle $@
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
|
||||||
|
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
|
||||||
|
-- 엔티티: com.eactive.apim.portal.apps.session.entity.UserSession
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE PTL_USER_SESSION
|
||||||
|
(
|
||||||
|
SESSION_ID VARCHAR2(128) NOT NULL,
|
||||||
|
USER_ID VARCHAR2(36) NOT NULL,
|
||||||
|
LOGIN_ID VARCHAR2(200) NOT NULL,
|
||||||
|
LOGIN_TIME TIMESTAMP(6) NOT NULL,
|
||||||
|
LAST_ACCESS_TIME TIMESTAMP(6) NOT NULL,
|
||||||
|
IP_ADDRESS VARCHAR2(45),
|
||||||
|
USER_AGENT VARCHAR2(500),
|
||||||
|
FORCE_LOGOUT CHAR(1) DEFAULT 'N' NOT NULL,
|
||||||
|
CONSTRAINT PK_PTL_USER_SESSION PRIMARY KEY (SESSION_ID)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 중복로그인 강제 로그아웃 / 활성 세션 조회는 LOGIN_ID 기준
|
||||||
|
CREATE INDEX IX_PTL_USER_SESSION_LOGIN ON PTL_USER_SESSION (LOGIN_ID);
|
||||||
|
|
||||||
|
COMMENT ON TABLE PTL_USER_SESSION IS '포털 사용자 세션 추적 (타이머/유휴 로그아웃/중복로그인)';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.SESSION_ID IS 'HTTP 세션 ID (PK)';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.USER_ID IS '포털 사용자 ID';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_ID IS '로그인 ID (이메일, 소문자)';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_TIME IS '로그인 시각';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.LAST_ACCESS_TIME IS '마지막 접근 시각 (만료 판단 기준)';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.IP_ADDRESS IS '접속 IP';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.USER_AGENT IS 'User-Agent';
|
||||||
|
COMMENT ON COLUMN PTL_USER_SESSION.FORCE_LOGOUT IS '강제 로그아웃 플래그 (Y/N)';
|
||||||
|
|
||||||
|
-- (선택) 세션 타임아웃(분) 설정값을 미리 지정. 미삽입 시 최초 접근 때 기본값 15로 자동 생성됨.
|
||||||
|
-- INSERT INTO PTL_PROPERTY (PROPERTY_GROUP_NAME, PROPERTY_NAME, PROPERTY_VALUE, PROPERTY_DESC)
|
||||||
|
-- VALUES ('Portal', 'session.timeout.minutes', '15', '세션 타임아웃 시간 (분)');
|
||||||
|
-- COMMIT;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
|
||||||
|
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
|
||||||
|
-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
|
||||||
|
--
|
||||||
|
-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
|
||||||
|
-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
|
||||||
|
-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
|
||||||
|
-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
|
||||||
|
-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
|
||||||
|
--
|
||||||
|
-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
|
||||||
|
-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
|
||||||
|
|
||||||
|
-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
|
||||||
|
CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Gateway 일별 처리 통계 (AGWAPP.API_STATS_DAY) — 읽기 전용.
|
||||||
|
*
|
||||||
|
* 월별 조회 시 "현재 월"은 아직 API_STATS_MONTH 에 집계되지 않으므로(월 집계 잡은 익월 1일 실행)
|
||||||
|
* 현재 월분은 이 DAY 테이블에서 조회한다.
|
||||||
|
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "API_STATS_DAY")
|
||||||
|
@IdClass(ApiStatsDayId.class)
|
||||||
|
public class ApiStatsDay implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "STAT_TIME", nullable = false)
|
||||||
|
private LocalDate statTime;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||||
|
private String apiName;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||||
|
private String gwInstanceId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
|
||||||
|
private String bizDivCode = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CLIENT_ID", nullable = false, length = 100)
|
||||||
|
private String clientId = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String inboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String outboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Column(name = "TOTAL_CNT", nullable = false)
|
||||||
|
private Long totalCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SUCCESS_CNT", nullable = false)
|
||||||
|
private Long successCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "TIMEOUT_CNT", nullable = false)
|
||||||
|
private Long timeoutCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
|
||||||
|
private Long systemErrCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "BIZ_ERR_CNT", nullable = false)
|
||||||
|
private Long bizErrCnt = 0L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 통계 일별 복합키
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiStatsDayId implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private LocalDate statTime;
|
||||||
|
private String apiName;
|
||||||
|
private String gwInstanceId;
|
||||||
|
private String bizDivCode;
|
||||||
|
private String clientId;
|
||||||
|
private String inboundAdapter;
|
||||||
|
private String outboundAdapter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Gateway 시간별 처리 통계 (AGWAPP.API_STATS_HOUR).
|
||||||
|
*
|
||||||
|
* 스키마명은 소스에 하드코딩하지 않는다 — gateway datasource 의 hibernate.default_schema(AGWAPP)로 해석된다.
|
||||||
|
* 포털은 읽기 전용으로만 사용한다(집계 주체는 eapim-admin 스케줄러). 사용 컬럼(카운트)만 매핑한다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "API_STATS_HOUR")
|
||||||
|
@IdClass(ApiStatsHourId.class)
|
||||||
|
public class ApiStatsHour implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "STAT_TIME", nullable = false)
|
||||||
|
private LocalDateTime statTime;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||||
|
private String apiName;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||||
|
private String gwInstanceId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
|
||||||
|
private String bizDivCode = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CLIENT_ID", nullable = false, length = 100)
|
||||||
|
private String clientId = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String inboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String outboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Column(name = "TOTAL_CNT", nullable = false)
|
||||||
|
private Long totalCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SUCCESS_CNT", nullable = false)
|
||||||
|
private Long successCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "TIMEOUT_CNT", nullable = false)
|
||||||
|
private Long timeoutCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
|
||||||
|
private Long systemErrCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "BIZ_ERR_CNT", nullable = false)
|
||||||
|
private Long bizErrCnt = 0L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 통계 시간별 복합키
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiStatsHourId implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private LocalDateTime statTime;
|
||||||
|
private String apiName;
|
||||||
|
private String gwInstanceId;
|
||||||
|
private String bizDivCode;
|
||||||
|
private String clientId;
|
||||||
|
private String inboundAdapter;
|
||||||
|
private String outboundAdapter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Gateway 월별 처리 통계 (AGWAPP.API_STATS_MONTH).
|
||||||
|
*
|
||||||
|
* STAT_TIME 은 YYYYMM 문자열. 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||||
|
* 포털은 읽기 전용으로만 사용한다. 사용 컬럼(카운트)만 매핑한다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "API_STATS_MONTH")
|
||||||
|
@IdClass(ApiStatsMonthId.class)
|
||||||
|
public class ApiStatsMonth implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "STAT_TIME", nullable = false, length = 6)
|
||||||
|
private String statTime; // YYYYMM format
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||||
|
private String apiName;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||||
|
private String gwInstanceId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
|
||||||
|
private String bizDivCode = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CLIENT_ID", nullable = false, length = 100)
|
||||||
|
private String clientId = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String inboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String outboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Column(name = "TOTAL_CNT", nullable = false)
|
||||||
|
private Long totalCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SUCCESS_CNT", nullable = false)
|
||||||
|
private Long successCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "TIMEOUT_CNT", nullable = false)
|
||||||
|
private Long timeoutCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
|
||||||
|
private Long systemErrCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "BIZ_ERR_CNT", nullable = false)
|
||||||
|
private Long bizErrCnt = 0L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 통계 월별 복합키
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiStatsMonthId implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String statTime; // YYYYMM format
|
||||||
|
private String apiName;
|
||||||
|
private String gwInstanceId;
|
||||||
|
private String bizDivCode;
|
||||||
|
private String clientId;
|
||||||
|
private String inboundAdapter;
|
||||||
|
private String outboundAdapter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GW 인증 Client 정보 (AGWAPP.TSEAIAU01) — 읽기 전용.
|
||||||
|
*
|
||||||
|
* API 통계의 org 스코핑에 사용한다: 통계 테이블에는 org_id 가 없으므로
|
||||||
|
* TSEAIAU01.ORGID 로 org 의 CLIENTID 목록을 구해 API_STATS_*.CLIENT_ID 와 매칭한다.
|
||||||
|
* (admin ApiUseStatsService 도 API_STATS.CLIENT_ID = TSEAIAU01.CLIENTID 로 조인)
|
||||||
|
*
|
||||||
|
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "TSEAIAU01")
|
||||||
|
public class GwAuthClient implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CLIENTID", nullable = false, length = 256)
|
||||||
|
private String clientId;
|
||||||
|
|
||||||
|
@Column(name = "CLIENTNAME", length = 150)
|
||||||
|
private String clientName;
|
||||||
|
|
||||||
|
@Column(name = "ORGID", length = 38)
|
||||||
|
private String orgId;
|
||||||
|
|
||||||
|
@Column(name = "APPSTATUS", length = 1)
|
||||||
|
private String appStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GW 인터페이스(서비스) 정보 (AGWAPP.TSEAIHE01) — 읽기 전용.
|
||||||
|
*
|
||||||
|
* API_STATS_*.API_NAME 은 인터페이스 ID(=EAISVCNAME)이며, 화면 표시용 실제 명칭은 EAISVCDESC 다.
|
||||||
|
* (admin ApiUseStatsService 도 API_STATS.API_NAME = TSEAIHE01.EAISVCNAME 으로 조인)
|
||||||
|
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "TSEAIHE01")
|
||||||
|
public class GwSvcInfo implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "EAISVCNAME", nullable = false, length = 30)
|
||||||
|
private String svcName; // 인터페이스 ID (= API_STATS.API_NAME)
|
||||||
|
|
||||||
|
@Column(name = "EAISVCDESC", length = 400)
|
||||||
|
private String svcDesc; // 인터페이스 표시 명칭
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDay;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDayId;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API_STATS_DAY(AGWAPP) 조회 리포지토리.
|
||||||
|
* 월별 조회의 "현재 월" 분(아직 API_STATS_MONTH 미집계)을 일 범위로 합산하는 데 사용한다.
|
||||||
|
*/
|
||||||
|
public interface ApiStatsDayRepository extends JpaRepository<ApiStatsDay, ApiStatsDayId> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 요약 (일자 범위, 지정 clientId 집합).
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||||
|
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsDay s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startDate AND :endDate")
|
||||||
|
ApiStatisticsSummaryDto findSummary(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startDate") LocalDate startDate,
|
||||||
|
@Param("endDate") LocalDate endDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API별 통계 (API_NAME 단위 집계).
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||||
|
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsDay s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||||
|
"GROUP BY s.apiName " +
|
||||||
|
"ORDER BY SUM(s.totalCnt) DESC")
|
||||||
|
List<ApiStatisticsDetailDto> findDetail(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startDate") LocalDate startDate,
|
||||||
|
@Param("endDate") LocalDate endDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일자별 누적 통계 (월별 조회 하단 표에 사용 — 월별이라도 일별로 표기).
|
||||||
|
* 생성자 표현식 안의 FUNCTION 은 Hibernate 5.6 이 타입 해석을 못 하므로 Object[] 로 받는다.
|
||||||
|
* row: [0]=일자(String yyyy-MM-dd), [1]=total, [2]=success, [3]=timeout, [4]=systemErr, [5]=biz
|
||||||
|
*/
|
||||||
|
@Query("SELECT FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD'), " +
|
||||||
|
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0) " +
|
||||||
|
"FROM ApiStatsDay s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||||
|
"GROUP BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD') " +
|
||||||
|
"ORDER BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD')")
|
||||||
|
List<Object[]> findPeriodByDay(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startDate") LocalDate startDate,
|
||||||
|
@Param("endDate") LocalDate endDate);
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHour;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHourId;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API_STATS_HOUR(AGWAPP) 조회 리포지토리.
|
||||||
|
*
|
||||||
|
* 통계 수치는 보존기간이 긴 API_STATS_DAY 를 사용하되, "오늘"은 아직 DAY 로 집계되지 않았으므로
|
||||||
|
* (일 집계 잡은 익일 실행) 오늘분만 HOUR 에서 합산해 DAY 결과에 더한다. 최신 집계 시각도 HOUR 로 산출.
|
||||||
|
*/
|
||||||
|
public interface ApiStatsHourRepository extends JpaRepository<ApiStatsHour, ApiStatsHourId> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 요약 (시각 범위, 지정 clientId 집합) — 오늘분 합산용.
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||||
|
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsHour s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startTime AND :endTime")
|
||||||
|
ApiStatisticsSummaryDto findSummary(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API별 통계 (API_NAME 단위 집계) — 오늘분 합산용.
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||||
|
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsHour s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startTime AND :endTime " +
|
||||||
|
"GROUP BY s.apiName " +
|
||||||
|
"ORDER BY SUM(s.totalCnt) DESC")
|
||||||
|
List<ApiStatisticsDetailDto> findDetail(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최신 집계 시각 (안내 문구용). 데이터 없으면 null.
|
||||||
|
*/
|
||||||
|
@Query("SELECT MAX(s.statTime) FROM ApiStatsHour s WHERE s.clientId IN :clientIds")
|
||||||
|
LocalDateTime findLatestStatTime(@Param("clientIds") List<String> clientIds);
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonth;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonthId;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API_STATS_MONTH(AGWAPP) 통계 조회 리포지토리 (월별 모드).
|
||||||
|
* STAT_TIME 은 YYYYMM 문자열이므로 문자열 범위 비교로 조회한다.
|
||||||
|
*/
|
||||||
|
public interface ApiStatsMonthRepository extends JpaRepository<ApiStatsMonth, ApiStatsMonthId> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 요약 (YYYYMM 범위, 지정 clientId 집합).
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||||
|
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsMonth s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startMonth AND :endMonth")
|
||||||
|
ApiStatisticsSummaryDto findSummary(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startMonth") String startMonth,
|
||||||
|
@Param("endMonth") String endMonth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API별 통계 (API_NAME 단위 집계).
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||||
|
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsMonth s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startMonth AND :endMonth " +
|
||||||
|
"GROUP BY s.apiName " +
|
||||||
|
"ORDER BY SUM(s.totalCnt) DESC")
|
||||||
|
List<ApiStatisticsDetailDto> findDetail(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startMonth") String startMonth,
|
||||||
|
@Param("endMonth") String endMonth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 가능한 월 목록 (YYYYMM, 최신순).
|
||||||
|
*/
|
||||||
|
@Query("SELECT DISTINCT s.statTime FROM ApiStatsMonth s " +
|
||||||
|
"WHERE s.clientId IN :clientIds ORDER BY s.statTime DESC")
|
||||||
|
List<String> findAvailableMonths(@Param("clientIds") List<String> clientIds);
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.GwAuthClient;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TSEAIAU01(AGWAPP 인증 Client) 조회 — API 통계 org 스코핑용.
|
||||||
|
* PTL_ORG.ID == TSEAIAU01.ORGID (1:N), TSEAIAU01.CLIENTID == API_STATS_*.CLIENT_ID.
|
||||||
|
*/
|
||||||
|
public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, String> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* org 소속 클라이언트 목록 (앱 선택 드롭다운용).
|
||||||
|
*/
|
||||||
|
List<GwAuthClient> findByOrgIdOrderByClientNameAsc(String orgId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* org 소속 CLIENTID 목록 (통계 필터용).
|
||||||
|
*/
|
||||||
|
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
||||||
|
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.GwSvcInfo;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TSEAIHE01(AGWAPP 인터페이스 정보) 조회 — API_NAME(인터페이스 ID) → 표시명(EAISVCDESC) 매핑용.
|
||||||
|
*/
|
||||||
|
public interface GwSvcInfoRepository extends JpaRepository<GwSvcInfo, String> {
|
||||||
|
|
||||||
|
List<GwSvcInfo> findBySvcNameIn(Collection<String> svcNames);
|
||||||
|
}
|
||||||
+32
-7
@@ -15,7 +15,11 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/agreements")
|
@RequestMapping("/agreements")
|
||||||
public class AgreementsController {
|
public class AgreementsController {
|
||||||
public static final String TERMS_AGREEMENTS = "fragment/kjbank/terms_agreements";
|
public static final String TERMS_AGREEMENTS = "fragment/djbank/terms_agreements";
|
||||||
|
|
||||||
|
/** 개인정보처리방침은 약관 페이지에서 분리되어 제주은행 공식 사이트 외부 링크로 대체됨 */
|
||||||
|
public static final String PRIVACY_POLICY_EXTERNAL_URL =
|
||||||
|
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
|
||||||
|
|
||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementsFacade agreementsFacade;
|
||||||
|
|
||||||
@@ -28,28 +32,49 @@ public class AgreementsController {
|
|||||||
public String showTerms(@RequestParam(required = false) String tab,
|
public String showTerms(@RequestParam(required = false) String tab,
|
||||||
@RequestParam(required = false) String publishedOn,
|
@RequestParam(required = false) String publishedOn,
|
||||||
Model model) {
|
Model model) {
|
||||||
|
String currentTab = tab != null ? tab : "terms";
|
||||||
|
|
||||||
|
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
|
||||||
|
if ("privacy".equals(currentTab)) {
|
||||||
|
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
|
||||||
|
}
|
||||||
|
|
||||||
AgreementType type;
|
AgreementType type;
|
||||||
if ("privacy".equals(tab)) {
|
switch (currentTab) {
|
||||||
type = AgreementType.PRIVACY_POLICY;
|
case "privacy-collect":
|
||||||
} else {
|
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
|
||||||
|
type = AgreementType.PRIVACY_COLLECT;
|
||||||
|
break;
|
||||||
|
case "notification":
|
||||||
|
type = AgreementType.NOTIFICATION_CONSENT;
|
||||||
|
break;
|
||||||
|
case "terms":
|
||||||
|
default:
|
||||||
|
currentTab = "terms";
|
||||||
type = AgreementType.TERMS_OF_USE;
|
type = AgreementType.TERMS_OF_USE;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
|
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
|
||||||
model.addAttribute("agreementsList", agreementsList);
|
model.addAttribute("agreementsList", agreementsList);
|
||||||
|
|
||||||
AgreementsDTO selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
|
// 해당 약관 종류가 아직 시드되지 않았을 수 있으므로 빈 리스트 방어
|
||||||
|
AgreementsDTO selectedAgreement = null;
|
||||||
|
if (agreementsList != null && !agreementsList.isEmpty()) {
|
||||||
|
selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
|
||||||
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
|
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
model.addAttribute("selectedAgreement", selectedAgreement);
|
model.addAttribute("selectedAgreement", selectedAgreement);
|
||||||
model.addAttribute("selectedDate", publishedOn);
|
model.addAttribute("selectedDate", publishedOn);
|
||||||
|
|
||||||
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
|
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
|
||||||
model.addAttribute("isPrivacyPolicy", type == AgreementType.PRIVACY_POLICY);
|
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
|
||||||
|
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
|
||||||
model.addAttribute("agreementTitle", type.getDescription());
|
model.addAttribute("agreementTitle", type.getDescription());
|
||||||
model.addAttribute("agreementType", type.getCode());
|
model.addAttribute("agreementType", type.getCode());
|
||||||
|
|
||||||
model.addAttribute("currentTab", tab != null ? tab : "terms");
|
model.addAttribute("currentTab", currentTab);
|
||||||
|
|
||||||
return TERMS_AGREEMENTS;
|
return TERMS_AGREEMENTS;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -71,8 +71,9 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
|||||||
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
|
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 개인정보수집동의서 처리
|
// 2. 개인정보수집동의서 처리 (개인정보수집동의서 = PRIVACY_COLLECT 신설항목 포함)
|
||||||
if (privacyCollectType != null && privacyCollectType.isPrivacyCollect()) {
|
if (privacyCollectType != null
|
||||||
|
&& (privacyCollectType.isPrivacyCollect() || privacyCollectType == AgreementType.PRIVACY_COLLECT)) {
|
||||||
Optional<Agreements> privacyCollect = agreementsService
|
Optional<Agreements> privacyCollect = agreementsService
|
||||||
.findLastesBeforeDate(
|
.findLastesBeforeDate(
|
||||||
privacyCollectType,
|
privacyCollectType,
|
||||||
@@ -199,6 +200,10 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
|||||||
defaultAgreement.setName("개인정보처리방침");
|
defaultAgreement.setName("개인정보처리방침");
|
||||||
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
break;
|
break;
|
||||||
|
case "PRIVACY_COLLECT":
|
||||||
|
defaultAgreement.setName("개인정보수집동의서");
|
||||||
|
defaultAgreement.setContents("현재 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
break;
|
||||||
case "PRIVACY_COLLECT_IND":
|
case "PRIVACY_COLLECT_IND":
|
||||||
defaultAgreement.setName("개인용 개인정보수집동의서");
|
defaultAgreement.setName("개인용 개인정보수집동의서");
|
||||||
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
|||||||
+25
-15
@@ -2,15 +2,15 @@ package com.eactive.apim.portal.apps.apis.controller;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
|
||||||
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.util.FileCopyUtils;
|
import org.springframework.util.FileCopyUtils;
|
||||||
@@ -22,35 +22,45 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/apis")
|
@RequestMapping("/api/apis")
|
||||||
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class TestbedSpecController {
|
public class TestbedSpecController {
|
||||||
|
|
||||||
@Autowired
|
private final ApiSpecInfoService apiSpecInfoService;
|
||||||
private ApiSpecInfoService apiSpecInfoService;
|
private final DjbTestbedSpecServerRewriter serverRewriter;
|
||||||
|
|
||||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||||
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
|
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
|
||||||
|
|
||||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public ResponseEntity<String> getSwagger(@PathVariable String id) {
|
public ResponseEntity<String> getSwagger(@PathVariable String id, HttpServletRequest request) {
|
||||||
|
String json = buildSpecJson(id, request);
|
||||||
|
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||||
|
public ResponseEntity<String> getSwaggerYaml(@PathVariable String id, HttpServletRequest request) {
|
||||||
|
String json = buildSpecJson(id, request);
|
||||||
|
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
|
||||||
|
private String buildSpecJson(String id, HttpServletRequest request) {
|
||||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||||
try {
|
try {
|
||||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
|
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||||
return ResponseEntity.ok().body(content);
|
return serverRewriter.rewriteServer(content, null, request);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Failed to read default token api spec file", e);
|
log.error("Failed to read default token api spec file", e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||||
|
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||||
if (!spec.isPresent()) {
|
return null;
|
||||||
StringUtils.hasText(spec.get().getTestbedSpec());
|
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
|
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.eactive.apim.portal.apps.apis.filter;
|
package com.eactive.apim.portal.apps.apis.filter;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -20,6 +21,20 @@ public class APISender {
|
|||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
|
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
|
||||||
|
|
||||||
|
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
|
||||||
|
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||||
|
|
||||||
|
public APISender(DjbTestbedGatewayProperty gatewayProperty) {
|
||||||
|
this.gatewayProperty = gatewayProperty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** connect/read 타임아웃 적용. 반드시 connect(getOutputStream/getResponseCode) 이전에 호출. */
|
||||||
|
private void applyTimeout(HttpURLConnection connection) {
|
||||||
|
int t = gatewayProperty.timeoutMillis();
|
||||||
|
connection.setConnectTimeout(t);
|
||||||
|
connection.setReadTimeout(t);
|
||||||
|
}
|
||||||
|
|
||||||
public String requestPost(String uri, String requestBody) throws IOException {
|
public String requestPost(String uri, String requestBody) throws IOException {
|
||||||
|
|
||||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||||
@@ -58,6 +73,7 @@ public class APISender {
|
|||||||
|
|
||||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||||
|
applyTimeout(connection);
|
||||||
connection.setRequestMethod("GET");
|
connection.setRequestMethod("GET");
|
||||||
connection.setRequestProperty("Accept", "application/json");
|
connection.setRequestProperty("Accept", "application/json");
|
||||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||||
@@ -82,6 +98,7 @@ public class APISender {
|
|||||||
|
|
||||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||||
|
applyTimeout(connection);
|
||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
|
|
||||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||||
@@ -125,10 +142,11 @@ public class APISender {
|
|||||||
return response.toString();
|
return response.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||||
URL endpoint = new URL(uri);
|
URL endpoint = new URL(uri);
|
||||||
|
|
||||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||||
|
applyTimeout(connection);
|
||||||
|
|
||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ package com.eactive.apim.portal.apps.apis.filter;
|
|||||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||||
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
||||||
|
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||||
|
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@@ -20,6 +22,7 @@ import javax.servlet.ServletRequest;
|
|||||||
import javax.servlet.ServletResponse;
|
import javax.servlet.ServletResponse;
|
||||||
import javax.servlet.annotation.WebFilter;
|
import javax.servlet.annotation.WebFilter;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@@ -65,7 +68,23 @@ public class ApiTesterFilter implements Filter {
|
|||||||
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
||||||
String url = httpServletRequest.getHeader("original-url");
|
String url = httpServletRequest.getHeader("original-url");
|
||||||
|
|
||||||
if (url.contains("/api/v1/oauth/token")){
|
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||||
|
if (url == null || url.trim().isEmpty()) {
|
||||||
|
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
|
||||||
|
// 반환하기 위해 try 로 감싼다.
|
||||||
|
try {
|
||||||
|
|
||||||
|
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
|
||||||
|
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
|
||||||
|
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
|
||||||
|
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||||
|
|| url.contains(gatewayProperty.tokenPath());
|
||||||
|
|
||||||
|
if (tokenRequest) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
BufferedReader reader = httpServletRequest.getReader();
|
BufferedReader reader = httpServletRequest.getReader();
|
||||||
String line;
|
String line;
|
||||||
@@ -74,7 +93,8 @@ public class ApiTesterFilter implements Filter {
|
|||||||
}
|
}
|
||||||
String body = sb.toString();
|
String body = sb.toString();
|
||||||
|
|
||||||
// Parse request parameters
|
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||||
|
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||||
Map<String, String> params = new HashMap<>();
|
Map<String, String> params = new HashMap<>();
|
||||||
String[] pairs = body.split("&");
|
String[] pairs = body.split("&");
|
||||||
for (String pair : pairs) {
|
for (String pair : pairs) {
|
||||||
@@ -95,63 +115,154 @@ public class ApiTesterFilter implements Filter {
|
|||||||
|
|
||||||
response.setContentType("application/json");
|
response.setContentType("application/json");
|
||||||
response.getWriter().println(token);
|
response.getWriter().println(token);
|
||||||
|
} else {
|
||||||
|
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||||
|
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||||
|
Map<String, String> headers = new HashMap<>();
|
||||||
|
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||||
|
headers.put("Accept", "application/json");
|
||||||
|
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
|
||||||
|
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||||
|
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.getWriter().println(tokenResponse);
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
|
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
|
||||||
|
|
||||||
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
|
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||||
|
if (apiSpecInfoDto == null) {
|
||||||
|
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||||
|
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String responseType = apiSpecInfoDto.getResponseType();
|
||||||
|
|
||||||
|
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||||
|
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||||
response.setContentType("application/json");
|
response.setContentType("application/json");
|
||||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||||
} else {
|
return;
|
||||||
String mockUrl = apiSpecInfoDto.getMockUrl();
|
}
|
||||||
String method = apiSpecInfoDto.getApiMethod();
|
|
||||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
|
||||||
|
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||||
|
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||||
|
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
Map<String, String> headers = new HashMap<>();
|
||||||
|
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||||
while (headerNames.hasMoreElements()) {
|
while (headerNames.hasMoreElements()) {
|
||||||
String header = headerNames.nextElement();
|
String header = headerNames.nextElement();
|
||||||
headers.put(header, httpServletRequest.getHeader(header));
|
headers.put(header, httpServletRequest.getHeader(header));
|
||||||
}
|
}
|
||||||
Map<String, String[]> paramMap = new HashMap<>();
|
|
||||||
|
|
||||||
String originalUrl = headers.get("original-url");
|
|
||||||
//sprlit original url with ? get the second part then split with & put into paramMap
|
|
||||||
|
|
||||||
String[] originalUrlArr = originalUrl.split("\\?");
|
|
||||||
if (originalUrlArr.length > 1) {
|
|
||||||
String[] paramArr = originalUrlArr[1].split("&");
|
|
||||||
for (String param : paramArr) {
|
|
||||||
String[] paramKeyValue = param.split("=");
|
|
||||||
if (paramKeyValue.length > 1) {
|
|
||||||
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.remove("original-url");
|
headers.remove("original-url");
|
||||||
headers.remove("original-api-id");
|
headers.remove("original-api-id");
|
||||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
|
||||||
|
|
||||||
String responseStr = "";
|
|
||||||
if (method.equalsIgnoreCase("post")) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
BufferedReader reader = httpServletRequest.getReader();
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
sb.append(line);
|
|
||||||
}
|
|
||||||
String body = sb.toString();
|
|
||||||
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
|
|
||||||
|
|
||||||
|
String targetUri;
|
||||||
|
Map<String, String[]> paramMap;
|
||||||
|
if (gw) {
|
||||||
|
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
|
||||||
|
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
|
||||||
|
headers.remove("host");
|
||||||
|
headers.remove("Host");
|
||||||
|
targetUri = url;
|
||||||
|
paramMap = new HashMap<>();
|
||||||
} else {
|
} else {
|
||||||
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
|
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
|
||||||
|
targetUri = apiSpecInfoDto.getMockUrl();
|
||||||
|
paramMap = extractQueryParams(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||||
|
String responseStr;
|
||||||
|
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||||
|
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
|
||||||
|
} else {
|
||||||
|
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||||
}
|
}
|
||||||
response.setContentType("application/json");
|
response.setContentType("application/json");
|
||||||
response.getWriter().println(responseStr);
|
response.getWriter().println(responseStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch (java.net.SocketTimeoutException e) {
|
||||||
|
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
|
||||||
|
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
|
||||||
|
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
|
||||||
|
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||||
|
} catch (IOException e) {
|
||||||
|
// 연결 실패 등 네트워크 오류
|
||||||
|
logger.error("테스트베드 프록시 호출 실패", e);
|
||||||
|
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
|
||||||
|
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 그 외 예기치 못한 오류도 JSON 으로 반환
|
||||||
|
logger.error("테스트베드 프록시 처리 오류", e);
|
||||||
|
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||||
|
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 요청 본문 전체를 문자열로 읽는다. */
|
||||||
|
private String readBody(HttpServletRequest request) throws IOException {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
BufferedReader reader = request.getReader();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
sb.append(line);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
|
||||||
|
private Map<String, String[]> extractQueryParams(String originalUrl) {
|
||||||
|
Map<String, String[]> paramMap = new HashMap<>();
|
||||||
|
String[] parts = originalUrl.split("\\?");
|
||||||
|
if (parts.length > 1) {
|
||||||
|
for (String param : parts[1].split("&")) {
|
||||||
|
String[] kv = param.split("=");
|
||||||
|
if (kv.length > 1) {
|
||||||
|
paramMap.put(kv[0], new String[]{kv[1]});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paramMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 상태코드 + JSON 본문 응답. */
|
||||||
|
private void writeJson(ServletResponse response, int status, String json) throws IOException {
|
||||||
|
((HttpServletResponse) response).setStatus(status);
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.getWriter().println(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 문자열 값에 넣기 위한 최소 escape (예외 메시지 등). */
|
||||||
|
private String escapeJson(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
switch (c) {
|
||||||
|
case '\\': sb.append("\\\\"); break;
|
||||||
|
case '"': sb.append("\\\""); break;
|
||||||
|
case '\n': sb.append("\\n"); break;
|
||||||
|
case '\r': sb.append("\\r"); break;
|
||||||
|
case '\t': sb.append("\\t"); break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20) {
|
||||||
|
sb.append(String.format("\\u%04x", (int) c));
|
||||||
|
} else {
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void destroy() {
|
public void destroy() {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
|||||||
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
|
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
|
||||||
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
||||||
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
||||||
|
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
|
||||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
||||||
@@ -21,6 +22,7 @@ import java.util.Map;
|
|||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -44,6 +46,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import org.springframework.web.servlet.ModelAndView;
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/myapikey")
|
@RequestMapping("/myapikey")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -77,6 +80,7 @@ public class MyAppController {
|
|||||||
private final ApiService apiService;
|
private final ApiService apiService;
|
||||||
private final ApiServiceHelper apiServiceHelper;
|
private final ApiServiceHelper apiServiceHelper;
|
||||||
private final FileTypeDetector fileTypeDetector;
|
private final FileTypeDetector fileTypeDetector;
|
||||||
|
private final AdminGatewayClient adminGatewayClient;
|
||||||
|
|
||||||
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
@@ -168,7 +172,13 @@ public class MyAppController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Client Secret은 초기 HTML에 담지 않는다. (비번 확인 후 서버가 1회만 반환)
|
||||||
|
boolean secretAvailable = StringUtils.isNotBlank(apiKey.getClientsecret());
|
||||||
|
apiKey.setClientsecret(null);
|
||||||
|
|
||||||
model.addAttribute("apiKey", apiKey);
|
model.addAttribute("apiKey", apiKey);
|
||||||
|
model.addAttribute("secretAvailable", secretAvailable);
|
||||||
|
model.addAttribute("authType", "OAuth2");
|
||||||
|
|
||||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||||
}
|
}
|
||||||
@@ -228,10 +238,7 @@ public class MyAppController {
|
|||||||
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
|
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
|
||||||
Map<String, Object> result = new java.util.HashMap<>();
|
Map<String, Object> result = new java.util.HashMap<>();
|
||||||
|
|
||||||
try {
|
|
||||||
String clientId = requestData.get("clientId");
|
String clientId = requestData.get("clientId");
|
||||||
String type = requestData.get("type");
|
|
||||||
|
|
||||||
if (clientId == null || clientId.trim().isEmpty()) {
|
if (clientId == null || clientId.trim().isEmpty()) {
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
result.put("msg", "클라이언트 ID가 필요합니다.");
|
result.put("msg", "클라이언트 ID가 필요합니다.");
|
||||||
@@ -239,17 +246,37 @@ public class MyAppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
String orgId = user.getPortalOrg().getId();
|
||||||
|
|
||||||
// API Key 즉시 삭제 (승인 프로세스 없이)
|
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
|
||||||
appServiceFacade.deleteApp(user.getPortalOrg().getId(), clientId);
|
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
|
||||||
|
try {
|
||||||
|
adminGatewayClient.blockClient(clientId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. GW 차단 성공 시에만 포털 credential 삭제
|
||||||
|
try {
|
||||||
|
appServiceFacade.deleteApp(orgId, clientId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
result.put("success", true);
|
result.put("success", true);
|
||||||
result.put("msg", "API Key가 삭제되었습니다.");
|
result.put("msg", "API Key가 삭제되었습니다.");
|
||||||
} catch (Exception e) {
|
|
||||||
result.put("success", false);
|
|
||||||
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +309,57 @@ public class MyAppController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
|
||||||
|
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
|
||||||
|
*
|
||||||
|
* @param requestData clientId, password 포함
|
||||||
|
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
|
||||||
|
*/
|
||||||
|
@PostMapping("/credential/reveal-secret")
|
||||||
|
@Secured("ROLE_APP")
|
||||||
|
@ResponseBody
|
||||||
|
public Map<String, Object> revealClientSecret(@RequestBody Map<String, String> requestData) {
|
||||||
|
Map<String, Object> result = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
String clientId = requestData.get("clientId");
|
||||||
|
String password = requestData.get("password");
|
||||||
|
|
||||||
|
if (clientId == null || clientId.trim().isEmpty()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "클라이언트 ID가 필요합니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
|
||||||
|
// 1. 본인 확인 (비밀번호)
|
||||||
|
if (!appServiceFacade.verifyUserPassword(user, password)) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
|
||||||
|
try {
|
||||||
|
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
|
||||||
|
if (secret == null) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("alreadyRevealed", true);
|
||||||
|
result.put("message", "이미 1회 노출되어 삭제된 인증정보입니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("secret", secret);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Client Secret 노출 처리 실패 - clientId={}", clientId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "인증정보 조회 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key 요청 이력을 페이지 형태로 조회합니다.
|
* API Key 요청 이력을 페이지 형태로 조회합니다.
|
||||||
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
|
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.eactive.apim.portal.apps.app.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||||
|
*
|
||||||
|
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||||
|
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminGatewayClient {
|
||||||
|
|
||||||
|
private static final String PROP_GROUP = "Portal";
|
||||||
|
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||||
|
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||||
|
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||||
|
*
|
||||||
|
* @param clientId 차단할 클라이언트 ID
|
||||||
|
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||||
|
*/
|
||||||
|
public void blockClient(String clientId) {
|
||||||
|
String baseUrl = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
||||||
|
|
||||||
|
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
|
||||||
|
|
||||||
|
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
||||||
|
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||||
|
|
||||||
|
Map<?, ?> body = response.getBody();
|
||||||
|
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||||
|
if (!success) {
|
||||||
|
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||||
|
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -293,6 +293,29 @@ public class AppServiceFacade {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client Secret을 1회 조회하고 그 즉시 DB(PTL_CREDENTIAL)에서 물리 삭제합니다.
|
||||||
|
* 보안 정책상 비밀정보는 최초 1회만 제공되며, 조회 후에는 복구할 수 없습니다.
|
||||||
|
*
|
||||||
|
* @param orgId 조직 ID (소유권 확인용)
|
||||||
|
* @param clientId 대상 클라이언트 ID
|
||||||
|
* @return 삭제 전 Client Secret 값. 이미 노출되어 비어있으면 {@code null}
|
||||||
|
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
public String revealAndDeleteClientSecret(String orgId, String clientId) {
|
||||||
|
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
|
||||||
|
|
||||||
|
String secret = credential.getClientsecret();
|
||||||
|
if (StringUtils.isBlank(secret)) {
|
||||||
|
return null; // 이미 1회 노출되어 삭제됨
|
||||||
|
}
|
||||||
|
|
||||||
|
credential.setClientsecret(null); // 물리 삭제 (1회 제공)
|
||||||
|
credentialRepository.save(credential);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key(Credential)를 즉시 삭제합니다.
|
* API Key(Credential)를 즉시 삭제합니다.
|
||||||
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
||||||
|
|||||||
@@ -31,4 +31,21 @@ public class ApprovalDTO {
|
|||||||
private LocalDateTime createdDate;
|
private LocalDateTime createdDate;
|
||||||
|
|
||||||
private LocalDateTime approvalDate;
|
private LocalDateTime approvalDate;
|
||||||
|
|
||||||
|
private String expectEndDate; //예상완료일 (yyyyMMdd)
|
||||||
|
|
||||||
|
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
|
||||||
|
public String getExpectEndDateDayOfWeek() {
|
||||||
|
if (expectEndDate == null || expectEndDate.length() < 8) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return java.time.LocalDate
|
||||||
|
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||||
|
.getDayOfWeek()
|
||||||
|
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,31 +3,72 @@ package com.eactive.apim.portal.apps.auth.service;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
|
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
|
||||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class AuthNumberStorage {
|
public class AuthNumberStorage {
|
||||||
|
|
||||||
|
private static final String HASH_ALGORITHM = "HmacSHA256";
|
||||||
|
|
||||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
|
||||||
|
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
|
||||||
|
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
|
||||||
|
*/
|
||||||
|
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
||||||
|
private String hashPepper;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
|
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
|
||||||
this.twoFactorAuthRepository = twoFactorAuthRepository;
|
this.twoFactorAuthRepository = twoFactorAuthRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
|
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
|
||||||
twoFactorAuthRepository.deleteAllByRecipient(recipientKey);
|
String lookupKey = hashRecipient(recipientKey);
|
||||||
|
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
|
||||||
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
|
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
|
||||||
|
auth.setRecipientKey(lookupKey);
|
||||||
twoFactorAuthRepository.save(auth);
|
twoFactorAuthRepository.save(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
|
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
|
||||||
return twoFactorAuthRepository.findByRecipient(recipientKey);
|
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteAuthNumber(String recipientKey) {
|
public void deleteAuthNumber(String recipientKey) {
|
||||||
twoFactorAuthRepository.deleteById(recipientKey);
|
twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recipient 평문을 결정적 해시(HMAC-SHA256, 소문자 hex)로 변환한다.
|
||||||
|
* 동일 입력 → 동일 키이므로 저장/조회/정리에서 동등 매칭이 가능하다.
|
||||||
|
*/
|
||||||
|
private String hashRecipient(String recipient) {
|
||||||
|
if (recipient == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Mac mac = Mac.getInstance(HASH_ALGORITHM);
|
||||||
|
mac.init(new SecretKeySpec(hashPepper.getBytes(StandardCharsets.UTF_8), HASH_ALGORITHM));
|
||||||
|
byte[] digest = mac.doFinal(recipient.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||||
|
for (byte b : digest) {
|
||||||
|
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||||
|
sb.append(Character.forDigit(b & 0xF, 16));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (GeneralSecurityException e) {
|
||||||
|
throw new IllegalStateException("2FA recipient 해시 생성 실패", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.constant;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Application constants.
|
|
||||||
*/
|
|
||||||
public final class Constants {
|
|
||||||
|
|
||||||
public static final String SYSTEM_ACCOUNT = "system";
|
|
||||||
|
|
||||||
public static final String APIM_FOR_OBP_KEY = "x-obp-trust-system";
|
|
||||||
|
|
||||||
public static final String APIM_CODE_FOR_OBP_KEY = "x-obp-partnercode";
|
|
||||||
|
|
||||||
public static final String SMS_URL = "/api/messaging/sms";
|
|
||||||
|
|
||||||
public static final String COMMISSION_URL = "/api/billing/billing/findBillingForCondition";
|
|
||||||
|
|
||||||
public static final String COMMISSION_PRINT_URL = "/api/billing/billing/findBillingForCondition/print";
|
|
||||||
|
|
||||||
public static final String OBP_ORGANIZATION_URL = "/api/customer/findCustomerByBusinessManRegistrationNo/";
|
|
||||||
|
|
||||||
public static String LANGUAGE = "ko";
|
|
||||||
|
|
||||||
private Constants() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-320
@@ -1,320 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.controller;
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.commission.constant.Constants;
|
|
||||||
import com.eactive.apim.portal.apps.commission.dto.CommissionDTO;
|
|
||||||
import com.eactive.apim.portal.apps.commission.dto.CommissionSearch;
|
|
||||||
import com.eactive.apim.portal.apps.commission.exception.ErrorUtil;
|
|
||||||
import com.eactive.apim.portal.apps.commission.service.ExternalService;
|
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.commons.collections.MapUtils;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.access.annotation.Secured;
|
|
||||||
import org.springframework.stereotype.Controller;
|
|
||||||
import org.springframework.ui.Model;
|
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
|
|
||||||
|
|
||||||
@Controller
|
|
||||||
@RequestMapping(value = "/commission")
|
|
||||||
@Secured("ROLE_APP")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CommissionController {
|
|
||||||
|
|
||||||
private final Logger log = LoggerFactory.getLogger(CommissionController.class);
|
|
||||||
|
|
||||||
private final ExternalService externalService;
|
|
||||||
private final PortalPropertyService portalPropertyService;
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper()
|
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
|
||||||
|
|
||||||
@GetMapping("/manage")
|
|
||||||
public String commissionManagePage(Model model) {
|
|
||||||
// 기본 정보 설정
|
|
||||||
model.addAttribute("organization", SecurityUtil.getUserOrg());
|
|
||||||
model.addAttribute("noOrgCode", StringUtils.isEmpty(SecurityUtil.getUserOrg().getOrgCode()));
|
|
||||||
return "apps/commission/commissionManage";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/print/{year}/{month}")
|
|
||||||
public String commissionPrintPage(
|
|
||||||
@PathVariable("year") String year,
|
|
||||||
@PathVariable("month") String month,
|
|
||||||
Model model) {
|
|
||||||
|
|
||||||
CommissionSearch search = new CommissionSearch();
|
|
||||||
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
|
|
||||||
search.setYear(year);
|
|
||||||
search.setMonth(month);
|
|
||||||
|
|
||||||
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
|
|
||||||
|
|
||||||
if (result.hasError()) {
|
|
||||||
model.addAttribute("error", result.getError());
|
|
||||||
return "apps/commission/commissionPrint";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model에 데이터 바인딩
|
|
||||||
model.addAttribute("commission", result.getData());
|
|
||||||
model.addAttribute("organization", SecurityUtil.getUserOrg().getOrgName());
|
|
||||||
model.addAttribute("year", year);
|
|
||||||
model.addAttribute("month", month);
|
|
||||||
model.addAttribute("toDay", java.time.LocalDate.now().toString());
|
|
||||||
|
|
||||||
return "apps/commission/commissionPrint";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== API Endpoints ====================
|
|
||||||
|
|
||||||
@PostMapping("/print/check")
|
|
||||||
public ResponseEntity<?> checkPrintData(@RequestBody CommissionSearch search) {
|
|
||||||
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
|
|
||||||
|
|
||||||
if (StringUtils.isEmpty(search.getPartnerCode())) {
|
|
||||||
return ErrorUtil.create("fail.commission.needPartnerCode");
|
|
||||||
}
|
|
||||||
|
|
||||||
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
|
|
||||||
|
|
||||||
if (result.hasError()) {
|
|
||||||
return ErrorUtil.create("fail.commission.print", result.getError());
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok().body(result.getData());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping()
|
|
||||||
public ResponseEntity<?> list(@RequestBody CommissionSearch search) {
|
|
||||||
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
|
|
||||||
|
|
||||||
if (StringUtils.isEmpty(search.getPartnerCode())) {
|
|
||||||
return ErrorUtil.create("fail.commission.needPartnerCode");
|
|
||||||
}
|
|
||||||
|
|
||||||
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_URL);
|
|
||||||
|
|
||||||
if (result.hasError()) {
|
|
||||||
return ErrorUtil.create("fail.commission.list", result.getError());
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok().body(result.getData());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Private Methods ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API를 호출하여 수수료 데이터를 조회합니다.
|
|
||||||
*
|
|
||||||
* @param search 검색 조건
|
|
||||||
* @param apiPath API 경로 (Constants.COMMISSION_URL 또는 Constants.COMMISSION_PRINT_URL)
|
|
||||||
* @return 조회 결과
|
|
||||||
*/
|
|
||||||
private CommissionResult fetchCommissionData(CommissionSearch search, String apiPath) {
|
|
||||||
if (StringUtils.isEmpty(search.getPartnerCode())) {
|
|
||||||
return CommissionResult.error("파트너 코드가 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
String obmUrl = portalPropertyService.getPortalPropertiesAsMap("Portal").getOrDefault("obp.obm.url", "");
|
|
||||||
String url = obmUrl + apiPath;
|
|
||||||
|
|
||||||
// 첫 번째 API 호출
|
|
||||||
String result = externalService.getResponseHttpPost(url, toJson(search));
|
|
||||||
if (StringUtils.isEmpty(result)) {
|
|
||||||
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, Object> returnObj = objectMapper.readValue(result, HashMap.class);
|
|
||||||
|
|
||||||
// 에러 응답 체크
|
|
||||||
String errorMessage = extractErrorMessage(returnObj);
|
|
||||||
if (errorMessage != null) {
|
|
||||||
return CommissionResult.error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Together 관련 추가 처리 (000002-01)
|
|
||||||
if (StringUtils.pathEquals(search.getPartnerCode(), "000002-01")) {
|
|
||||||
log.debug("Found Together : " + search.getPartnerCode());
|
|
||||||
|
|
||||||
CommissionSearch togetherSearch = new CommissionSearch();
|
|
||||||
togetherSearch.setPartnerCode("000002-02");
|
|
||||||
togetherSearch.setYear(search.getYear());
|
|
||||||
togetherSearch.setMonth(search.getMonth());
|
|
||||||
|
|
||||||
String togetherResult = externalService.getResponseHttpPost(url, toJson(togetherSearch));
|
|
||||||
if (StringUtils.isEmpty(togetherResult)) {
|
|
||||||
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, Object> togetherReturnObj = objectMapper.readValue(togetherResult, HashMap.class);
|
|
||||||
log.debug("togetherLendingReturnObj : " + togetherReturnObj.toString());
|
|
||||||
|
|
||||||
String togetherErrorMessage = extractErrorMessage(togetherReturnObj);
|
|
||||||
if (togetherErrorMessage != null) {
|
|
||||||
return CommissionResult.error(togetherErrorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, Object> mergeMap = mergeCommissionData(returnObj, togetherReturnObj);
|
|
||||||
result = objectMapper.writeValueAsString(mergeMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
CommissionDTO commissionDTO = objectMapper.readValue(result, CommissionDTO.class);
|
|
||||||
return CommissionResult.success(commissionDTO);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn(e.getMessage(), e);
|
|
||||||
return CommissionResult.error("수수료 조회에 실패하였습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 응답에서 에러 메시지를 추출합니다.
|
|
||||||
*/
|
|
||||||
private String extractErrorMessage(HashMap<String, Object> response) {
|
|
||||||
if (!StringUtils.isEmpty(response.get("error"))) {
|
|
||||||
HashMap<String, Object> error = (HashMap) response.get("error");
|
|
||||||
return error.get("message").toString();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toJson(CommissionSearch search) {
|
|
||||||
try {
|
|
||||||
Map<String, String> jsonMap = new HashMap<>();
|
|
||||||
jsonMap.put("partnerCode", search.getPartnerCode());
|
|
||||||
// 월을 두 자리로 패딩 (예: 1 -> 01, 12 -> 12)
|
|
||||||
String paddedMonth = String.format("%02d", Integer.parseInt(search.getMonth()));
|
|
||||||
jsonMap.put("targetOnMonth", search.getYear() + paddedMonth);
|
|
||||||
|
|
||||||
return objectMapper.writeValueAsString(jsonMap);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to serialize CommissionSearch to JSON", e);
|
|
||||||
return "{}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely parse double value from HashMap
|
|
||||||
* Handles both numeric types and string representations
|
|
||||||
*/
|
|
||||||
private double safeGetDouble(HashMap<String, Object> map, String key) {
|
|
||||||
Object value = map.get(key);
|
|
||||||
if (value == null) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (value instanceof Number) {
|
|
||||||
return ((Number) value).doubleValue();
|
|
||||||
} else if (value instanceof String) {
|
|
||||||
return Double.parseDouble((String) value);
|
|
||||||
}
|
|
||||||
return 0.0;
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
log.warn("Failed to parse double value for key: {} with value: {}", key, value);
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public HashMap<String, Object> mergeCommissionData(HashMap<String, Object> o1, HashMap<String, Object> o2) {
|
|
||||||
|
|
||||||
HashMap<String, Object> mergeMap = new HashMap<String, Object>();
|
|
||||||
|
|
||||||
String o1Status = MapUtils.getString(o1, "status");
|
|
||||||
String o2Status = MapUtils.getString(o2, "status");
|
|
||||||
String resultStatus = null;
|
|
||||||
if (Integer.parseInt(o1Status) <= Integer.parseInt(o2Status)) {
|
|
||||||
resultStatus = o1Status;
|
|
||||||
} else {
|
|
||||||
resultStatus = o2Status;
|
|
||||||
}
|
|
||||||
double apiTotalValueSum = safeGetDouble(o1, "apiTotalValueSum") + safeGetDouble(o2, "apiTotalValueSum");
|
|
||||||
double apiSum = safeGetDouble(o1, "apiSum") + safeGetDouble(o2, "apiSum");
|
|
||||||
double apiTotalValueSum2 = safeGetDouble(o1, "apiTotalValueSum2") + safeGetDouble(o2, "apiTotalValueSum2");
|
|
||||||
double apiSum2 = safeGetDouble(o1, "apiSum2") + safeGetDouble(o2, "apiSum2");
|
|
||||||
double productTotalValueCountSum = safeGetDouble(o1, "productTotalValueCountSum") + safeGetDouble(o2, "productTotalValueCountSum");
|
|
||||||
double productTotalValueAmountSum = safeGetDouble(o1, "productTotalValueAmountSum") + safeGetDouble(o2, "productTotalValueAmountSum");
|
|
||||||
double productSum = safeGetDouble(o1, "productSum") + safeGetDouble(o2, "productSum");
|
|
||||||
double productTotalValueCountSum2 = safeGetDouble(o1, "productTotalValueCountSum2") + safeGetDouble(o2, "productTotalValueCountSum2");
|
|
||||||
double productTotalValueAmountSum2 = safeGetDouble(o1, "productTotalValueAmountSum2") + safeGetDouble(o2, "productTotalValueAmountSum2");
|
|
||||||
double productSum2 = safeGetDouble(o1, "productSum2") + safeGetDouble(o2, "productSum2");
|
|
||||||
double totalValueCountSum = safeGetDouble(o1, "totalValueCountSum") + safeGetDouble(o2, "totalValueCountSum");
|
|
||||||
double totalValueCountSum2 = safeGetDouble(o1, "totalValueCountSum2") + safeGetDouble(o2, "totalValueCountSum2");
|
|
||||||
double totalValueAmountSum = safeGetDouble(o1, "totalValueAmountSum") + safeGetDouble(o2, "totalValueAmountSum");
|
|
||||||
double totalValueAmountSum2 = safeGetDouble(o1, "totalValueAmountSum2") + safeGetDouble(o2, "totalValueAmountSum2");
|
|
||||||
double sum = safeGetDouble(o1, "sum") + safeGetDouble(o2, "sum");
|
|
||||||
double sum2 = safeGetDouble(o1, "sum2") + safeGetDouble(o2, "sum2");
|
|
||||||
|
|
||||||
List<Map> o1List = (ArrayList) o1.get("list");
|
|
||||||
List<Map> o2pList = (ArrayList) o2.get("list");
|
|
||||||
o1List.addAll(o2pList);
|
|
||||||
|
|
||||||
mergeMap.put("status", resultStatus);
|
|
||||||
mergeMap.put("apiTotalValueSum", String.format("%.2f", apiTotalValueSum));
|
|
||||||
mergeMap.put("apiSum", String.format("%.2f", apiSum));
|
|
||||||
mergeMap.put("apiTotalValueSum2", String.format("%.2f", apiTotalValueSum2));
|
|
||||||
mergeMap.put("apiSum2", String.format("%.2f", apiSum2));
|
|
||||||
mergeMap.put("productTotalValueCountSum", String.format("%.2f", productTotalValueCountSum));
|
|
||||||
mergeMap.put("productTotalValueAmountSum", String.format("%.2f", productTotalValueAmountSum));
|
|
||||||
mergeMap.put("productSum", String.format("%.2f", productSum));
|
|
||||||
mergeMap.put("productTotalValueCountSum2", String.format("%.2f", productTotalValueCountSum2));
|
|
||||||
mergeMap.put("productTotalValueAmountSum2", String.format("%.2f", productTotalValueAmountSum2));
|
|
||||||
mergeMap.put("productSum2", String.format("%.2f", productSum2));
|
|
||||||
mergeMap.put("totalValueCountSum", String.format("%.2f", totalValueCountSum));
|
|
||||||
mergeMap.put("totalValueCountSum2", String.format("%.2f", totalValueCountSum2));
|
|
||||||
mergeMap.put("totalValueAmountSum", String.format("%.2f", totalValueAmountSum));
|
|
||||||
mergeMap.put("totalValueAmountSum2", String.format("%.2f", totalValueAmountSum2));
|
|
||||||
mergeMap.put("sum", String.format("%.2f", sum));
|
|
||||||
mergeMap.put("sum2", String.format("%.2f", sum2));
|
|
||||||
mergeMap.put("list", o1List);
|
|
||||||
|
|
||||||
return mergeMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Inner Classes ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수수료 조회 결과를 담는 내부 클래스
|
|
||||||
*/
|
|
||||||
private static class CommissionResult {
|
|
||||||
private CommissionDTO data;
|
|
||||||
private String error;
|
|
||||||
|
|
||||||
static CommissionResult success(CommissionDTO data) {
|
|
||||||
CommissionResult result = new CommissionResult();
|
|
||||||
result.data = data;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static CommissionResult error(String message) {
|
|
||||||
CommissionResult result = new CommissionResult();
|
|
||||||
result.error = message;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean hasError() {
|
|
||||||
return error != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
CommissionDTO getData() {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
String getError() {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.dto;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class CommissionDTO {
|
|
||||||
|
|
||||||
private String status;
|
|
||||||
private String apiTotalValueSum;
|
|
||||||
private String apiSum;
|
|
||||||
private String apiTotalValueSum2;
|
|
||||||
private String apiSum2;
|
|
||||||
private String productTotalValueCountSum;
|
|
||||||
private String productTotalValueAmountSum;
|
|
||||||
private String productSum;
|
|
||||||
private String productTotalValueCountSum2;
|
|
||||||
private String productTotalValueAmountSum2;
|
|
||||||
private String productSum2;
|
|
||||||
private String totalValueCountSum;
|
|
||||||
private String totalValueCountSum2;
|
|
||||||
private String totalValueAmountSum;
|
|
||||||
private String totalValueAmountSum2;
|
|
||||||
private String sum;
|
|
||||||
private String sum2;
|
|
||||||
private List<CommissionList> list;
|
|
||||||
private String documentFormatNo;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created by jskim on 17. 01. 26.
|
|
||||||
*
|
|
||||||
* @author jskim
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public class CommissionList {
|
|
||||||
|
|
||||||
private String paymentTargetName;
|
|
||||||
private String paymentTargetType;
|
|
||||||
private String value;
|
|
||||||
private String minimumAmount;
|
|
||||||
private String maximumAmount;
|
|
||||||
private String totalCount;
|
|
||||||
private String totalCount2;
|
|
||||||
private String totalValue;
|
|
||||||
private String totalValue2;
|
|
||||||
private String minimumAmount2;
|
|
||||||
private String maximumAmount2;
|
|
||||||
private String paymentBaseName;
|
|
||||||
private String paymentTimingName;
|
|
||||||
private String valueDivisionName;
|
|
||||||
private String sum;
|
|
||||||
private String sum2;
|
|
||||||
private String detailFeeTypeName;
|
|
||||||
private String value2;
|
|
||||||
private String changeReason2;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.dto;
|
|
||||||
|
|
||||||
import io.swagger.annotations.ApiModelProperty;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class CommissionSearch {
|
|
||||||
|
|
||||||
@ApiModelProperty(value = "대상년")
|
|
||||||
private String year;
|
|
||||||
|
|
||||||
@ApiModelProperty(value = "대상월")
|
|
||||||
private String month;
|
|
||||||
|
|
||||||
private String partnerCode;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.exception;
|
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created by ybsong on 16. 12. 7.
|
|
||||||
*
|
|
||||||
* @author ybsong
|
|
||||||
*/
|
|
||||||
public class ErrorUtil {
|
|
||||||
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message, String description) {
|
|
||||||
return new ResponseEntity<>(new ErrorVM(message, description), status);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message) {
|
|
||||||
return create(status, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ResponseEntity<ErrorVM> create(String message, String description) {
|
|
||||||
return create(HttpStatus.BAD_REQUEST, message, description);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ResponseEntity<ErrorVM> create(String message) {
|
|
||||||
return create(message, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.exception;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* View Model for transferring error message with a list of field errors.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public class ErrorVM implements Serializable {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final String message;
|
|
||||||
private final String description;
|
|
||||||
|
|
||||||
private List<FieldErrorVM> fieldErrors;
|
|
||||||
|
|
||||||
public ErrorVM(String message) {
|
|
||||||
this(message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ErrorVM(String message, String description) {
|
|
||||||
this.message = message;
|
|
||||||
this.description = description;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ErrorVM(String message, String description, List<FieldErrorVM> fieldErrors) {
|
|
||||||
this.message = message;
|
|
||||||
this.description = description;
|
|
||||||
this.fieldErrors = fieldErrors;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void add(String objectName, String field, String message) {
|
|
||||||
if (fieldErrors == null) {
|
|
||||||
fieldErrors = new ArrayList<>();
|
|
||||||
}
|
|
||||||
fieldErrors.add(new FieldErrorVM(objectName, field, message));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.exception;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
|
|
||||||
public class FieldErrorVM implements Serializable {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final String objectName;
|
|
||||||
|
|
||||||
private final String field;
|
|
||||||
|
|
||||||
private final String message;
|
|
||||||
|
|
||||||
public FieldErrorVM(String dto, String field, String message) {
|
|
||||||
this.objectName = dto;
|
|
||||||
this.field = field;
|
|
||||||
this.message = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getObjectName() {
|
|
||||||
return objectName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getField() {
|
|
||||||
return field;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getMessage() {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.service;
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.commission.constant.Constants;
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
|
||||||
import java.util.Map;
|
|
||||||
import javax.inject.Inject;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.http.HttpEntity;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.client.HttpStatusCodeException;
|
|
||||||
import org.springframework.web.client.RestClientException;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API 호출 서비스
|
|
||||||
* RestTemplate 기반으로 외부 시스템과 HTTP 통신을 수행합니다.
|
|
||||||
*
|
|
||||||
* @author ybsong
|
|
||||||
* @since 2017-03-02
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@Transactional
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ExternalService {
|
|
||||||
|
|
||||||
private final Logger log = LoggerFactory.getLogger(ExternalService.class);
|
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
private final PortalPropertyService portalPropertyService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP POST 요청을 통해 외부 API를 호출하고 응답을 받습니다.
|
|
||||||
*
|
|
||||||
* @param url 호출할 API URL
|
|
||||||
* @param payload 요청 본문 (JSON 문자열)
|
|
||||||
* @return API 응답 본문 (문자열), 오류 발생 시 null
|
|
||||||
*/
|
|
||||||
public String getResponseHttpPost(String url, String payload) {
|
|
||||||
try {
|
|
||||||
|
|
||||||
Map<String, String> portalProperties = portalPropertyService.getPortalPropertiesAsMap("Portal");
|
|
||||||
|
|
||||||
// HTTP 헤더 설정
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
headers.set(Constants.APIM_FOR_OBP_KEY, portalProperties.getOrDefault("obp.api_key","APIMPT-0002-QVBJTVBULTAwMDI="));
|
|
||||||
headers.set(Constants.APIM_CODE_FOR_OBP_KEY, portalProperties.getOrDefault("obp.partner_code","100001-01"));
|
|
||||||
|
|
||||||
// HTTP 요청 엔티티 생성 (헤더 + 본문)
|
|
||||||
HttpEntity<String> requestEntity = new HttpEntity<>(payload, headers);
|
|
||||||
|
|
||||||
// POST 요청 실행
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
|
|
||||||
|
|
||||||
// 응답 본문 반환
|
|
||||||
return response.getBody();
|
|
||||||
|
|
||||||
} catch (HttpStatusCodeException e) {
|
|
||||||
// 4xx, 5xx 에러더라도 응답 본문이 있으면 반환 (에러 메시지 포함)
|
|
||||||
String responseBody = e.getResponseBodyAsString();
|
|
||||||
log.warn("HTTP error from external API: url={}, status={}, body={}", url, e.getStatusCode(), responseBody);
|
|
||||||
if (responseBody != null && !responseBody.isEmpty()) {
|
|
||||||
return responseBody;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (RestClientException e) {
|
|
||||||
log.error("Failed to call external API: url={}, error={}", url, e.getMessage(), e);
|
|
||||||
return null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Unexpected error during HTTP POST: url={}", url, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+13
@@ -13,6 +13,7 @@ import org.springframework.ui.Model;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class FaqController {
|
public class FaqController {
|
||||||
@@ -47,4 +48,16 @@ public class FaqController {
|
|||||||
|
|
||||||
return "apps/community/mainFaqList";
|
return "apps/community/mainFaqList";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/faq_view")
|
||||||
|
public String view(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
|
if (id == null) {
|
||||||
|
return "redirect:/faq_list";
|
||||||
|
}
|
||||||
|
|
||||||
|
FaqDTO faq = faqFacade.getFaq(id);
|
||||||
|
model.addAttribute("faq", faq);
|
||||||
|
|
||||||
|
return "apps/community/mainFaqDetail";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ import org.springframework.data.domain.Pageable;
|
|||||||
|
|
||||||
public interface FaqFacade {
|
public interface FaqFacade {
|
||||||
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
|
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
|
||||||
|
|
||||||
|
FaqDTO getFaq(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,4 +34,9 @@ public class FaqFacadeImpl implements FaqFacade {
|
|||||||
return faqPage.map(faqMapper::map);
|
return faqPage.map(faqMapper::map);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FaqDTO getFaq(String id) {
|
||||||
|
return faqMapper.map(faqService.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.eactive.apim.portal.apps.community.notice.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class IncidentAffectedApiDTO {
|
||||||
|
private String apiId;
|
||||||
|
private String apiName;
|
||||||
|
}
|
||||||
@@ -8,11 +8,18 @@ import org.hibernate.validator.constraints.Length;
|
|||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
import javax.validation.constraints.Size;
|
import javax.validation.constraints.Size;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class PortalNoticeDTO {
|
public class PortalNoticeDTO {
|
||||||
|
|
||||||
|
public static final String NOTICE_TYPE_NORMAL = "1";
|
||||||
|
public static final String NOTICE_TYPE_MAINTENANCE = "2";
|
||||||
|
public static final String NOTICE_TYPE_INCIDENT = "3";
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
@NotEmpty(message = "제목을 입력해주세요.")
|
@NotEmpty(message = "제목을 입력해주세요.")
|
||||||
@@ -29,9 +36,33 @@ public class PortalNoticeDTO {
|
|||||||
|
|
||||||
private boolean useYn;
|
private boolean useYn;
|
||||||
|
|
||||||
|
private String fixYn;
|
||||||
|
|
||||||
|
private String noticeType;
|
||||||
|
|
||||||
private String inquirerName;
|
private String inquirerName;
|
||||||
|
|
||||||
private LocalDateTime createdDate;
|
private LocalDateTime createdDate;
|
||||||
|
|
||||||
private LocalDateTime lastModifiedDate;
|
private LocalDateTime lastModifiedDate;
|
||||||
|
|
||||||
|
// ───── 장애/점검 연동 필드 ─────
|
||||||
|
private String summary;
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
private LocalDateTime endAt;
|
||||||
|
private String state;
|
||||||
|
private String previousState;
|
||||||
|
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
|
||||||
|
|
||||||
|
public boolean isIncidentType() {
|
||||||
|
return NOTICE_TYPE_INCIDENT.equals(noticeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMaintenanceType() {
|
||||||
|
return NOTICE_TYPE_MAINTENANCE.equals(noticeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isIncidentOrMaintenance() {
|
||||||
|
return isIncidentType() || isMaintenanceType();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+45
-3
@@ -1,8 +1,12 @@
|
|||||||
package com.eactive.apim.portal.apps.community.notice.service;
|
package com.eactive.apim.portal.apps.community.notice.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.community.notice.dto.IncidentAffectedApiDTO;
|
||||||
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
|
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
|
||||||
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
|
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
|
||||||
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
|
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
|
||||||
|
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||||
|
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
||||||
|
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
|
||||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -12,7 +16,10 @@ import org.springframework.data.domain.Sort;
|
|||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service("portalNoticeFacade")
|
@Service("portalNoticeFacade")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -20,12 +27,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
|||||||
|
|
||||||
private final PortalNoticeService portalNoticeService;
|
private final PortalNoticeService portalNoticeService;
|
||||||
private final PortalNoticeMapper portalNoticeMapper;
|
private final PortalNoticeMapper portalNoticeMapper;
|
||||||
|
private final DjbApistatusIncidentRepository incidentRepository;
|
||||||
|
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<PortalNoticeDTO> getLatestNotices() {
|
public List<PortalNoticeDTO> getLatestNotices() {
|
||||||
PortalNoticeSearch search = new PortalNoticeSearch();
|
PortalNoticeSearch search = new PortalNoticeSearch();
|
||||||
Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdDate"));
|
Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdDate"));
|
||||||
// Pageable pageable = Pageable.ofSize(3);
|
|
||||||
Page<PortalNoticeDTO> notices = getNotices(search, pageable);
|
Page<PortalNoticeDTO> notices = getNotices(search, pageable);
|
||||||
return notices.getContent();
|
return notices.getContent();
|
||||||
}
|
}
|
||||||
@@ -36,13 +44,47 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
|||||||
Specification<PortalNotice> spec = Specification.where(search.buildSpecification())
|
Specification<PortalNotice> spec = Specification.where(search.buildSpecification())
|
||||||
.and((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("useYn"),"Y"));
|
.and((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("useYn"),"Y"));
|
||||||
|
|
||||||
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, pageable);
|
// 상단고정(fixYn='Y') 우선 정렬 — 호출자의 sort 앞에 결합 (admin Service 와 일관)
|
||||||
|
Sort fixYnFirst = Sort.by(Sort.Direction.DESC, "fixYn");
|
||||||
|
Pageable fixedPageable = PageRequest.of(
|
||||||
|
pageable.getPageNumber(),
|
||||||
|
pageable.getPageSize(),
|
||||||
|
fixYnFirst.and(pageable.getSort())
|
||||||
|
);
|
||||||
|
|
||||||
|
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, fixedPageable);
|
||||||
return noticesPage.map(portalNoticeMapper::map);
|
return noticesPage.map(portalNoticeMapper::map);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PortalNoticeDTO getNotice(String id) {
|
public PortalNoticeDTO getNotice(String id) {
|
||||||
PortalNotice portalNotice = portalNoticeService.getNotice(id);
|
PortalNotice portalNotice = portalNoticeService.getNotice(id);
|
||||||
return portalNoticeMapper.map(portalNotice);
|
PortalNoticeDTO dto = portalNoticeMapper.map(portalNotice);
|
||||||
|
populateIncident(dto);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populateIncident(PortalNoticeDTO dto) {
|
||||||
|
if (!dto.isIncidentOrMaintenance()) {
|
||||||
|
dto.setAffectedApis(Collections.emptyList());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
|
||||||
|
if (!incidentOpt.isPresent()) {
|
||||||
|
dto.setAffectedApis(Collections.emptyList());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DjbApistatusIncident incident = incidentOpt.get();
|
||||||
|
dto.setSummary(incident.getSummary());
|
||||||
|
dto.setStartedAt(incident.getStartedAt());
|
||||||
|
dto.setEndAt(incident.getEndAt());
|
||||||
|
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||||
|
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
|
||||||
|
|
||||||
|
List<IncidentAffectedApiDTO> apis = incidentApiRepository
|
||||||
|
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
||||||
|
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
dto.setAffectedApis(apis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -40,6 +40,7 @@ public class PartnershipApplicationController {
|
|||||||
|
|
||||||
model.addAttribute("partnership", new PartnershipApplicationDTO());
|
model.addAttribute("partnership", new PartnershipApplicationDTO());
|
||||||
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
||||||
|
model.addAttribute("recentApplications", partnershipApplicationFacade.getMyRecentApplications());
|
||||||
return "apps/community/mainPartnershipForm";
|
return "apps/community/mainPartnershipForm";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ public class PartnershipApplicationController {
|
|||||||
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
||||||
|
|
||||||
// 성공 메시지 추가
|
// 성공 메시지 추가
|
||||||
redirectAttributes.addFlashAttribute("success", "사업제휴신청이 완료되었습니다.");
|
redirectAttributes.addFlashAttribute("success", "피드백/개선요청 등록이 완료되었습니다.");
|
||||||
|
|
||||||
httpServletRequest.getSession().removeAttribute("previousPage");
|
httpServletRequest.getSession().removeAttribute("previousPage");
|
||||||
return "redirect:/partnership";
|
return "redirect:/partnership";
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.eactive.apim.portal.apps.community.partnership.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성폼 상단 "내가 작성한 최근 글" 아코디언에 노출하기 위한 조회 전용 DTO.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class PartnershipApplicationSummaryDTO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
private String bizSubject;
|
||||||
|
|
||||||
|
private String bizDetail;
|
||||||
|
|
||||||
|
private LocalDateTime createdDate;
|
||||||
|
}
|
||||||
+6
@@ -2,8 +2,10 @@ package com.eactive.apim.portal.apps.community.partnership.mapper;
|
|||||||
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
||||||
|
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
|
||||||
import com.eactive.apim.portal.common.mapper.CommonMapper;
|
import com.eactive.apim.portal.common.mapper.CommonMapper;
|
||||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||||
|
import java.util.List;
|
||||||
import org.mapstruct.Mapper;
|
import org.mapstruct.Mapper;
|
||||||
import org.mapstruct.ReportingPolicy;
|
import org.mapstruct.ReportingPolicy;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -16,4 +18,8 @@ import org.springframework.stereotype.Component;
|
|||||||
public interface PartnershipApplicationMapper {
|
public interface PartnershipApplicationMapper {
|
||||||
|
|
||||||
PartnershipApplication toEntity(PartnershipApplicationDTO dto);
|
PartnershipApplication toEntity(PartnershipApplicationDTO dto);
|
||||||
|
|
||||||
|
PartnershipApplicationSummaryDTO toSummaryDto(PartnershipApplication entity);
|
||||||
|
|
||||||
|
List<PartnershipApplicationSummaryDTO> toSummaryDtoList(List<PartnershipApplication> entities);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -9,4 +10,10 @@ import org.springframework.stereotype.Repository;
|
|||||||
@Repository
|
@Repository
|
||||||
@EMSDataSource
|
@EMSDataSource
|
||||||
public interface PartnershipApplicationRepository extends JpaRepository<PartnershipApplication, String>, JpaSpecificationExecutor<PartnershipApplication> {
|
public interface PartnershipApplicationRepository extends JpaRepository<PartnershipApplication, String>, JpaSpecificationExecutor<PartnershipApplication> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 작성자(createdBy)가 등록한 최근 3건을 최신순으로 조회한다.
|
||||||
|
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
||||||
|
*/
|
||||||
|
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -1,9 +1,16 @@
|
|||||||
package com.eactive.apim.portal.apps.community.partnership.service;
|
package com.eactive.apim.portal.apps.community.partnership.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
||||||
|
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface PartnershipApplicationFacade {
|
public interface PartnershipApplicationFacade {
|
||||||
|
|
||||||
void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException;
|
void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
||||||
|
*/
|
||||||
|
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -1,18 +1,25 @@
|
|||||||
package com.eactive.apim.portal.apps.community.partnership.service;
|
package com.eactive.apim.portal.apps.community.partnership.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
||||||
|
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
|
||||||
import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipApplicationMapper;
|
import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipApplicationMapper;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||||
|
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||||
import com.eactive.apim.portal.file.service.FileService;
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||||
|
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -21,13 +28,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
|||||||
private final PartnershipApplicationService partnershipApplicationService;
|
private final PartnershipApplicationService partnershipApplicationService;
|
||||||
private final PartnershipApplicationMapper partnershipApplicationMapper;
|
private final PartnershipApplicationMapper partnershipApplicationMapper;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
|
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
|
||||||
|
private final CommunityAdminNotifier portalAdminNotifier;
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException {
|
public void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException {
|
||||||
|
|
||||||
FileInfo file = null;
|
FileInfo file = null;
|
||||||
if (!partnershipApplicationDTO.getFiles().isEmpty()) {
|
if (partnershipApplicationDTO.getFiles() != null && !partnershipApplicationDTO.getFiles().isEmpty()) {
|
||||||
try {
|
try {
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
|
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
|
||||||
@@ -43,5 +52,25 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
|||||||
partnershipApplication.setFileId(file.getFileId());
|
partnershipApplication.setFileId(file.getFileId());
|
||||||
}
|
}
|
||||||
partnershipApplicationService.createPartnershipApplication(partnershipApplication);
|
partnershipApplicationService.createPartnershipApplication(partnershipApplication);
|
||||||
|
|
||||||
|
// 개선요청/제휴 게시물 등록 시 portal-admin 에게 알림 (INQUIRY_CREATED 와 동일 패턴)
|
||||||
|
PortalAuthenticatedUser writer = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("partnershipId", partnershipApplication.getId());
|
||||||
|
params.put("bizSubject", partnershipApplication.getBizSubject());
|
||||||
|
if (writer != null) {
|
||||||
|
params.put("writerName", writer.getUserName());
|
||||||
|
}
|
||||||
|
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<PartnershipApplicationSummaryDTO> getMyRecentApplications() {
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
if (user == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
|
||||||
|
return partnershipApplicationMapper.toSummaryDtoList(recent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -21,4 +22,12 @@ public class PartnershipApplicationService {
|
|||||||
public void createPartnershipApplication(PartnershipApplication partnershipApplication) {
|
public void createPartnershipApplication(PartnershipApplication partnershipApplication) {
|
||||||
partnershipApplicationRepository.save(partnershipApplication);
|
partnershipApplicationRepository.save(partnershipApplication);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성자 id 로 최근 등록 3건 조회(최신순).
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
|
||||||
|
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-3
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
|||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
|
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -16,11 +17,13 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.web.PageableDefault;
|
import org.springframework.data.web.PageableDefault;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.validation.BindingResult;
|
import org.springframework.validation.BindingResult;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
@@ -56,6 +59,7 @@ public class InquiryController {
|
|||||||
model.addAttribute("inquiries", inquiries.getContent());
|
model.addAttribute("inquiries", inquiries.getContent());
|
||||||
model.addAttribute("page", inquiries);
|
model.addAttribute("page", inquiries);
|
||||||
model.addAttribute("commentCounts", commentCounts);
|
model.addAttribute("commentCounts", commentCounts);
|
||||||
|
model.addAttribute("visibilityCeiling", inquiryFacade.getVisibilityCeiling());
|
||||||
return "apps/community/mainInquiryList";
|
return "apps/community/mainInquiryList";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,13 +79,25 @@ public class InquiryController {
|
|||||||
return "apps/community/mainInquiryDetail";
|
return "apps/community/mainInquiryDetail";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2-1. 조회수 증가 (sessionStorage dedup 통과 시 JS 가 POST 호출)
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/view")
|
||||||
|
public ResponseEntity<Void> increaseViewCount(@PathVariable String id) {
|
||||||
|
inquiryFacade.incrementViewCount(SecurityUtil.getPortalAuthenticatedUser(), id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3. inquiry 등록 페이지
|
* 3. inquiry 등록 페이지
|
||||||
*/
|
*/
|
||||||
@GetMapping("/new")
|
@GetMapping("/new")
|
||||||
public String newInquiryForm(Model model) {
|
public String newInquiryForm(Model model) {
|
||||||
model.addAttribute("inquiry", new InquiryDTO());
|
InquiryDTO inquiry = new InquiryDTO();
|
||||||
|
inquiry.setVisibility(VisibilityScope.ORG.name()); // 기본 공개범위: 법인공개
|
||||||
|
model.addAttribute("inquiry", inquiry);
|
||||||
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
||||||
|
addVisibilityOptions(model);
|
||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,14 +109,23 @@ public class InquiryController {
|
|||||||
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
|
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
|
||||||
model.addAttribute("inquiry", inquiry);
|
model.addAttribute("inquiry", inquiry);
|
||||||
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
||||||
|
addVisibilityOptions(model);
|
||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 공개범위 상한에 따른 폼 옵션 노출 플래그. PRIVATE 는 항상 허용. */
|
||||||
|
private void addVisibilityOptions(Model model) {
|
||||||
|
VisibilityScope ceiling = inquiryFacade.getVisibilityCeiling();
|
||||||
|
model.addAttribute("allowAll", ceiling.width() >= VisibilityScope.ALL.width());
|
||||||
|
model.addAttribute("allowOrg", ceiling.width() >= VisibilityScope.ORG.width());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 5. inquiry 등록 요청
|
* 5. inquiry 등록 요청
|
||||||
*/
|
*/
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public String createInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
public String createInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
||||||
|
@RequestParam(value = "image", required = false) MultipartFile image,
|
||||||
RedirectAttributes redirectAttributes,
|
RedirectAttributes redirectAttributes,
|
||||||
Model model) throws IOException {
|
Model model) throws IOException {
|
||||||
if (bindingResult.hasErrors()) {
|
if (bindingResult.hasErrors()) {
|
||||||
@@ -108,7 +133,7 @@ public class InquiryController {
|
|||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||||
}
|
}
|
||||||
|
|
||||||
inquiryFacade.createInquiry(inquiryDTO);
|
inquiryFacade.createInquiry(inquiryDTO, image);
|
||||||
|
|
||||||
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
||||||
return REDIRECT_INQUIRY;
|
return REDIRECT_INQUIRY;
|
||||||
@@ -119,11 +144,12 @@ public class InquiryController {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/edit")
|
@PostMapping("/edit")
|
||||||
public String updateInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
public String updateInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
||||||
|
@RequestParam(value = "image", required = false) MultipartFile image,
|
||||||
RedirectAttributes redirectAttributes) throws IOException {
|
RedirectAttributes redirectAttributes) throws IOException {
|
||||||
if (bindingResult.hasErrors()) {
|
if (bindingResult.hasErrors()) {
|
||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||||
}
|
}
|
||||||
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO);
|
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO, image);
|
||||||
redirectAttributes.addFlashAttribute("success", "Q&A 수정이 완료되었습니다.");
|
redirectAttributes.addFlashAttribute("success", "Q&A 수정이 완료되었습니다.");
|
||||||
return "redirect:/inquiry/detail?id=" + inquiryDTO.getId();
|
return "redirect:/inquiry/detail?id=" + inquiryDTO.getId();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class InquiryDTO {
|
|||||||
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
|
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
|
||||||
private String inquiryDetail;
|
private String inquiryDetail;
|
||||||
|
|
||||||
@Pattern(regexp = "^(PENDING|IN_PROGRESS|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
@Pattern(regexp = "^(PENDING|REVIEWING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
||||||
private String inquiryStatus;
|
private String inquiryStatus;
|
||||||
|
|
||||||
private String inquirerName;
|
private String inquirerName;
|
||||||
@@ -51,4 +51,22 @@ public class InquiryDTO {
|
|||||||
|
|
||||||
private String maskedInquirerName;
|
private String maskedInquirerName;
|
||||||
|
|
||||||
|
/** 목록 작성자 표기용 법인명(이름과 별도 줄 표기). */
|
||||||
|
private String inquirerOrgName;
|
||||||
|
|
||||||
|
/** 상세 작성자 표기용 "이름 (법인명)". */
|
||||||
|
private String inquirerDisplay;
|
||||||
|
|
||||||
|
/** 공개범위(ALL/ORG/PRIVATE). 미지정 시 서버에서 상한으로 clamp. */
|
||||||
|
private String visibility;
|
||||||
|
|
||||||
|
/** 공개범위 표기용 한글 라벨(전체공개/법인공개/비공개). */
|
||||||
|
private String visibilityLabel;
|
||||||
|
|
||||||
|
/** 조회수(표시용). */
|
||||||
|
private long viewCount;
|
||||||
|
|
||||||
|
/** 목록에서 비공개 게시물을 "비공개 게시물"로 흐리게 표기할지 여부(비-소유자). */
|
||||||
|
private boolean privatePlaceholder;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import org.springframework.stereotype.Component;
|
|||||||
@Component
|
@Component
|
||||||
public interface InquiryMapper {
|
public interface InquiryMapper {
|
||||||
|
|
||||||
|
// visibility(공개범위)는 facade 에서 상한 clamp 후 수동 세팅, viewCount 는 서버가 관리
|
||||||
|
@Mapping(target = "visibility", ignore = true)
|
||||||
|
@Mapping(target = "viewCount", ignore = true)
|
||||||
Inquiry toEntity(InquiryDTO inquiry);
|
Inquiry toEntity(InquiryDTO inquiry);
|
||||||
|
|
||||||
@Mapping(target = "inquirerName", source = "inquirer.userName")
|
@Mapping(target = "inquirerName", source = "inquirer.userName")
|
||||||
|
|||||||
@@ -3,23 +3,30 @@ package com.eactive.apim.portal.apps.community.qna.service;
|
|||||||
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
|
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
|
||||||
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
|
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
public interface InquiryFacade {
|
public interface InquiryFacade {
|
||||||
|
|
||||||
Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user);
|
Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user);
|
||||||
|
|
||||||
|
/** 문의 공개범위 상한(폼 옵션 노출 제어용). */
|
||||||
|
VisibilityScope getVisibilityCeiling();
|
||||||
|
|
||||||
InquiryDTO getUserInquiry(PortalAuthenticatedUser user, String id);
|
InquiryDTO getUserInquiry(PortalAuthenticatedUser user, String id);
|
||||||
|
|
||||||
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
|
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
|
||||||
|
|
||||||
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
|
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
|
||||||
|
|
||||||
void createInquiry(InquiryDTO inquiryDTO) throws IOException;
|
void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
|
||||||
|
|
||||||
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException;
|
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
|
||||||
|
|
||||||
|
void incrementViewCount(PortalAuthenticatedUser user, String id);
|
||||||
|
|
||||||
void deleteUserInquiry(PortalAuthenticatedUser user, String id);
|
void deleteUserInquiry(PortalAuthenticatedUser user, String id);
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-6
@@ -6,27 +6,42 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
|
|||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryAdminNotifier;
|
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||||
|
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||||
|
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||||
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.io.FilenameUtils;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class InquiryFacadeImpl implements InquiryFacade {
|
public class InquiryFacadeImpl implements InquiryFacade {
|
||||||
|
|
||||||
|
private static final Set<String> IMAGE_EXTENSIONS =
|
||||||
|
new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "gif"));
|
||||||
|
|
||||||
private final InquiryService inquiryService;
|
private final InquiryService inquiryService;
|
||||||
private final InquiryMapper inquiryMapper;
|
private final InquiryMapper inquiryMapper;
|
||||||
private final InquiryAdminNotifier inquiryAdminNotifier;
|
private final CommunityAdminNotifier inquiryAdminNotifier;
|
||||||
|
private final FileService fileService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
|
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
|
||||||
@@ -40,6 +55,8 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
|
|
||||||
Page<Inquiry> inquiriesPage = inquiryService.getInquiries(spec, pageable);
|
Page<Inquiry> inquiriesPage = inquiryService.getInquiries(spec, pageable);
|
||||||
|
|
||||||
|
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
|
||||||
|
|
||||||
return inquiriesPage.map(inquiry -> {
|
return inquiriesPage.map(inquiry -> {
|
||||||
InquiryDTO dto = inquiryMapper.map(inquiry);
|
InquiryDTO dto = inquiryMapper.map(inquiry);
|
||||||
if (dto != null && dto.getInquirer() != null) {
|
if (dto != null && dto.getInquirer() != null) {
|
||||||
@@ -48,8 +65,20 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
inquiry.getInquirer().getId(),
|
inquiry.getInquirer().getId(),
|
||||||
user.getId()
|
user.getId()
|
||||||
));
|
));
|
||||||
|
PortalOrg org = inquiry.getInquirer().getPortalOrg();
|
||||||
|
if (org != null) {
|
||||||
|
dto.setInquirerOrgName(org.getOrgName());
|
||||||
|
}
|
||||||
dto.getInquirer().setUserName(null); // 원본 데이터 제거
|
dto.getInquirer().setUserName(null); // 원본 데이터 제거
|
||||||
}
|
}
|
||||||
|
// 비공개 게시물은 본인/같은 법인 관리자 외에는 "비공개 게시물"로만 노출
|
||||||
|
VisibilityScope effective = VisibilityScope.clampTo(inquiry.getVisibility(), ceiling);
|
||||||
|
dto.setVisibility(effective.name());
|
||||||
|
dto.setVisibilityLabel(effective.label());
|
||||||
|
if (effective == VisibilityScope.PRIVATE && !canReadPrivate(user, inquiry)) {
|
||||||
|
dto.setPrivatePlaceholder(true);
|
||||||
|
dto.setInquirySubject(null);
|
||||||
|
}
|
||||||
return dto;
|
return dto;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,14 +110,32 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
@Override
|
@Override
|
||||||
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
|
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
|
||||||
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
|
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
|
||||||
return inquiryMapper.map(inquiry);
|
InquiryDTO dto = inquiryMapper.map(inquiry);
|
||||||
|
if (dto != null) {
|
||||||
|
VisibilityScope effective = inquiryService.effectiveVisibility(inquiry);
|
||||||
|
dto.setVisibility(effective.name());
|
||||||
|
dto.setVisibilityLabel(effective.label());
|
||||||
|
}
|
||||||
|
if (dto != null && inquiry.getInquirer() != null) {
|
||||||
|
// 상세 작성자 표기: "이름 (법인명)" — 본인은 owner-bypass 로 원문
|
||||||
|
String maskedName = StringMaskingUtil.maskName(
|
||||||
|
inquiry.getInquirer().getUserName(),
|
||||||
|
inquiry.getInquirer().getId(),
|
||||||
|
user.getId());
|
||||||
|
dto.setInquirerDisplay(withOrgName(maskedName, inquiry.getInquirer()));
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
|
public void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
|
||||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||||
inquiry.setInquirer(current);
|
inquiry.setInquirer(current);
|
||||||
|
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
|
||||||
|
if (hasFile(image)) {
|
||||||
|
inquiry.setAttachFile(storeImage(null, image));
|
||||||
|
}
|
||||||
inquiryService.createInquiry(inquiry);
|
inquiryService.createInquiry(inquiry);
|
||||||
|
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
@@ -99,17 +146,91 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException {
|
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
|
||||||
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
|
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
|
||||||
inquiryDTO.setAttachFile(existingInquiry.getAttachFile());
|
|
||||||
|
|
||||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||||
inquiry.setInquirer(existingInquiry.getInquirer());
|
inquiry.setInquirer(existingInquiry.getInquirer());
|
||||||
|
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
|
||||||
|
// 이미지 교체 시 기존 fileId 로 업데이트, 없으면 기존 첨부 유지
|
||||||
|
if (hasFile(image)) {
|
||||||
|
inquiry.setAttachFile(storeImage(existingInquiry.getAttachFile(), image));
|
||||||
|
} else {
|
||||||
|
inquiry.setAttachFile(existingInquiry.getAttachFile());
|
||||||
|
}
|
||||||
inquiryService.updateInquiry(id, inquiry);
|
inquiryService.updateInquiry(id, inquiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void incrementViewCount(PortalAuthenticatedUser user, String id) {
|
||||||
|
// 접근 가능한 게시물만 조회수 증가
|
||||||
|
inquiryService.getAccessibleInquiry(user, id);
|
||||||
|
inquiryService.incrementViewCount(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VisibilityScope getVisibilityCeiling() {
|
||||||
|
return inquiryService.getVisibilityCeiling();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteUserInquiry(PortalAuthenticatedUser user, String id) {
|
public void deleteUserInquiry(PortalAuthenticatedUser user, String id) {
|
||||||
inquiryService.deleteMyInquiry(user, id);
|
inquiryService.deleteMyInquiry(user, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// helpers
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/** 요청 공개범위를 기본 ORG 로 두고 property 상한으로 clamp. */
|
||||||
|
private VisibilityScope clampedVisibility(String requested) {
|
||||||
|
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
|
||||||
|
VisibilityScope scope = VisibilityScope.fromString(requested, VisibilityScope.ORG);
|
||||||
|
return VisibilityScope.clampTo(scope, ceiling);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비공개 게시물을 읽을 수 있는 사용자(작성자 본인 또는 같은 법인 관리자). */
|
||||||
|
private boolean canReadPrivate(PortalAuthenticatedUser user, Inquiry inquiry) {
|
||||||
|
PortalUser inquirer = inquiry.getInquirer();
|
||||||
|
if (inquirer == null || user == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|
||||||
|
&& isSameOrg(user, inquirer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
|
||||||
|
PortalOrg userOrg = user.getPortalOrg();
|
||||||
|
PortalOrg inquirerOrg = inquirer.getPortalOrg();
|
||||||
|
return userOrg != null && inquirerOrg != null
|
||||||
|
&& userOrg.getId() != null
|
||||||
|
&& userOrg.getId().equals(inquirerOrg.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "이름 (법인명)" 조합. 법인명이 없으면 이름만. */
|
||||||
|
private String withOrgName(String name, PortalUser inquirer) {
|
||||||
|
PortalOrg org = inquirer.getPortalOrg();
|
||||||
|
if (org != null && org.getOrgName() != null && !org.getOrgName().isEmpty()) {
|
||||||
|
return name + " (" + org.getOrgName() + ")";
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasFile(MultipartFile file) {
|
||||||
|
return file != null && !file.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이미지 확장자 검증 후 단일 파일 저장, fileId 반환. */
|
||||||
|
private String storeImage(String existingFileId, MultipartFile image) throws IOException {
|
||||||
|
String ext = FilenameUtils.getExtension(image.getOriginalFilename());
|
||||||
|
if (ext == null || !IMAGE_EXTENSIONS.contains(ext.toLowerCase())) {
|
||||||
|
throw new InvalidFileException("이미지 파일(jpg, jpeg, png, gif)만 첨부할 수 있습니다.");
|
||||||
|
}
|
||||||
|
FileInfo fileInfo = fileService.createOrUpdateSingleFile(
|
||||||
|
existingFileId, image, image.getOriginalFilename(), true);
|
||||||
|
return fileInfo.getFileId();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-3
@@ -7,7 +7,9 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
|||||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||||
|
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
@@ -19,10 +21,19 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class InquiryService {
|
public class InquiryService {
|
||||||
|
|
||||||
public static final String PENDING = "PENDING";
|
public static final String PENDING = "PENDING";
|
||||||
private final InquiryRepository inquiryRepository;
|
|
||||||
|
|
||||||
public InquiryService(InquiryRepository inquiryRepository) {
|
/** 문의 공개범위 상한/기본값 property. */
|
||||||
|
private static final String PROP_GROUP = "Portal";
|
||||||
|
private static final String PROP_VISIBILITY_CEILING = "djb.inquiry.default-visibility";
|
||||||
|
private static final String PROP_VISIBILITY_DESC = "Q&A 문의 공개범위 상한/기본값 (ALL/ORG/PRIVATE)";
|
||||||
|
|
||||||
|
private final InquiryRepository inquiryRepository;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
public InquiryService(InquiryRepository inquiryRepository,
|
||||||
|
PortalPropertyService portalPropertyService) {
|
||||||
this.inquiryRepository = inquiryRepository;
|
this.inquiryRepository = inquiryRepository;
|
||||||
|
this.portalPropertyService = portalPropertyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +86,7 @@ public class InquiryService {
|
|||||||
inquiry.setInquirySubject(updatedInquiry.getInquirySubject());
|
inquiry.setInquirySubject(updatedInquiry.getInquirySubject());
|
||||||
inquiry.setInquiryDetail(updatedInquiry.getInquiryDetail());
|
inquiry.setInquiryDetail(updatedInquiry.getInquiryDetail());
|
||||||
inquiry.setAttachFile(updatedInquiry.getAttachFile());
|
inquiry.setAttachFile(updatedInquiry.getAttachFile());
|
||||||
|
inquiry.setVisibility(updatedInquiry.getVisibility());
|
||||||
return inquiryRepository.save(inquiry);
|
return inquiryRepository.save(inquiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,12 +124,27 @@ public class InquiryService {
|
|||||||
if (inquirer == null || user == null) {
|
if (inquirer == null || user == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// 작성자 본인은 공개범위와 무관하게 항상 접근 가능
|
||||||
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
|
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
VisibilityScope effective = effectiveVisibility(inquiry);
|
||||||
|
switch (effective) {
|
||||||
|
case ALL:
|
||||||
|
// 전체공개: 로그인 사용자면 접근 가능(@Secured 로 이미 인증됨)
|
||||||
|
return true;
|
||||||
|
case ORG:
|
||||||
|
return isSameOrg(user, inquirer);
|
||||||
|
case PRIVATE:
|
||||||
|
// 비공개: 본인 외에는 같은 법인 관리자만 열람
|
||||||
|
return isSameOrg(user, inquirer)
|
||||||
|
&& user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER;
|
||||||
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
|
||||||
PortalOrg userOrg = user.getPortalOrg();
|
PortalOrg userOrg = user.getPortalOrg();
|
||||||
PortalOrg inquirerOrg = inquirer.getPortalOrg();
|
PortalOrg inquirerOrg = inquirer.getPortalOrg();
|
||||||
return userOrg != null && inquirerOrg != null
|
return userOrg != null && inquirerOrg != null
|
||||||
@@ -125,4 +152,30 @@ public class InquiryService {
|
|||||||
&& userOrg.getId().equals(inquirerOrg.getId());
|
&& userOrg.getId().equals(inquirerOrg.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물의 유효 공개범위 = 저장값을 property 상한으로 clamp 한 값.
|
||||||
|
* (property 가 항상 우선하므로 저장값이 옛 상한이라 넓더라도 재-clamp)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public VisibilityScope effectiveVisibility(Inquiry inquiry) {
|
||||||
|
return VisibilityScope.clampTo(inquiry.getVisibility(), getVisibilityCeiling());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PTL_PROPERTY 에 설정된 공개범위 상한(없으면 ORG). */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public VisibilityScope getVisibilityCeiling() {
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, PROP_VISIBILITY_CEILING, VisibilityScope.ORG.name(), PROP_VISIBILITY_DESC);
|
||||||
|
return VisibilityScope.fromString(value, VisibilityScope.ORG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회수 1 증가. sessionStorage dedup 을 통과한 POST 요청에서만 호출된다.
|
||||||
|
*/
|
||||||
|
public void incrementViewCount(String id) {
|
||||||
|
Inquiry inquiry = getInquiry(id);
|
||||||
|
inquiry.increaseViewCount();
|
||||||
|
inquiryRepository.save(inquiry);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
-19
@@ -152,25 +152,6 @@ public class AccountRecoveryController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/resend_verification_email")
|
|
||||||
public ResponseEntity<Map<String, String>> resendVerificationEmail(@RequestParam String loginId) {
|
|
||||||
Map<String, String> response = new HashMap<>();
|
|
||||||
|
|
||||||
try {
|
|
||||||
portalUserAuthService.resendVerificationEmail(loginId);
|
|
||||||
response.put("success", "인증 이메일이 재발송되었습니다.");
|
|
||||||
return ResponseEntity.ok(response);
|
|
||||||
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
response.put("error", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(response);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
response.put("error", "이메일 발송 중 오류가 발생했습니다.");
|
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/dormant_account")
|
@GetMapping("/dormant_account")
|
||||||
public String showDormantAccountPage(HttpSession session,Model model) {
|
public String showDormantAccountPage(HttpSession session,Model model) {
|
||||||
|
|
||||||
|
|||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package com.eactive.apim.portal.apps.session.controller;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||||
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||||
|
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||||
|
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/session")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SessionApiController {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
private final UserSessionService userSessionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 전 중복 세션 확인
|
||||||
|
*/
|
||||||
|
@PostMapping("/check-duplicate")
|
||||||
|
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
|
||||||
|
|
||||||
|
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
|
||||||
|
|
||||||
|
if (activeSession.isPresent()) {
|
||||||
|
UserSession session = activeSession.get();
|
||||||
|
result.put("duplicateSession", true);
|
||||||
|
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
|
||||||
|
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
|
||||||
|
} else {
|
||||||
|
result.put("duplicateSession", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 상태 폴링 (인증 필요)
|
||||||
|
*/
|
||||||
|
@GetMapping("/status")
|
||||||
|
public ResponseEntity<Map<String, Object>> getSessionStatus(HttpServletRequest request) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
HttpSession httpSession = request.getSession(false);
|
||||||
|
|
||||||
|
if (httpSession == null) {
|
||||||
|
result.put("valid", false);
|
||||||
|
result.put("remainingSeconds", 0);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionId = httpSession.getId();
|
||||||
|
boolean forceLoggedOut = userSessionService.isForceLoggedOut(sessionId);
|
||||||
|
|
||||||
|
if (forceLoggedOut) {
|
||||||
|
result.put("valid", false);
|
||||||
|
result.put("remainingSeconds", 0);
|
||||||
|
} else {
|
||||||
|
long remaining = userSessionService.getRemainingSeconds(sessionId);
|
||||||
|
result.put("valid", remaining > 0);
|
||||||
|
result.put("remainingSeconds", remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하트비트 - lastAccessTime 갱신 (세션 연장)
|
||||||
|
*/
|
||||||
|
@PostMapping("/heartbeat")
|
||||||
|
public ResponseEntity<Map<String, Object>> heartbeat(HttpServletRequest request) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
HttpSession httpSession = request.getSession(false);
|
||||||
|
|
||||||
|
if (httpSession == null) {
|
||||||
|
result.put("remainingSeconds", 0);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionId = httpSession.getId();
|
||||||
|
userSessionService.updateLastAccessTime(sessionId);
|
||||||
|
long remaining = userSessionService.getRemainingSeconds(sessionId);
|
||||||
|
result.put("remainingSeconds", remaining);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
||||||
|
* 예: 192.168.240.178 → 192.168.***.178
|
||||||
|
*/
|
||||||
|
private String maskIpAddress(String ip) {
|
||||||
|
if (ip == null || ip.isEmpty()) {
|
||||||
|
return "알 수 없음";
|
||||||
|
}
|
||||||
|
String[] parts = ip.split("\\.");
|
||||||
|
if (parts.length == 4) {
|
||||||
|
return parts[0] + "." + parts[1] + ".***." + parts[3];
|
||||||
|
}
|
||||||
|
// IPv6 등 다른 형식은 일부만 표시
|
||||||
|
if (ip.length() > 8) {
|
||||||
|
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
|
||||||
|
}
|
||||||
|
return "***";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.eactive.apim.portal.apps.session.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 세션 추적 엔티티 (EMS 스키마, PTL_USER_SESSION)
|
||||||
|
*
|
||||||
|
* <p>화면 세션 타이머/유휴 자동 로그아웃 및 중복로그인 강제 로그아웃에 사용된다.
|
||||||
|
* {@code lastAccessTime} + 타임아웃(분) 이 세션 만료의 기준이며, {@code forceLogout='Y'} 인 세션은
|
||||||
|
* 다음 요청 시 {@link com.eactive.apim.portal.apps.session.filter.SessionValidationFilter} 가 강제 로그아웃한다.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Entity
|
||||||
|
@Table(name = "PTL_USER_SESSION")
|
||||||
|
public class UserSession {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "SESSION_ID", length = 128, nullable = false)
|
||||||
|
private String sessionId;
|
||||||
|
|
||||||
|
@Column(name = "USER_ID", length = 36, nullable = false)
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@Column(name = "LOGIN_ID", length = 200, nullable = false)
|
||||||
|
private String loginId;
|
||||||
|
|
||||||
|
@Column(name = "LOGIN_TIME", nullable = false)
|
||||||
|
private LocalDateTime loginTime;
|
||||||
|
|
||||||
|
@Column(name = "LAST_ACCESS_TIME", nullable = false)
|
||||||
|
private LocalDateTime lastAccessTime;
|
||||||
|
|
||||||
|
@Column(name = "IP_ADDRESS", length = 45)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Column(name = "USER_AGENT", length = 500)
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
@Column(name = "FORCE_LOGOUT", length = 1, nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private String forceLogout = "N";
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package com.eactive.apim.portal.apps.session.filter;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import javax.servlet.FilterChain;
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 사용자 요청마다 DB 세션 상태를 검증한다.
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>강제 로그아웃(forceLogout='Y') 감지 시 세션 무효화 후 {@code /login?forceLogout=true}</li>
|
||||||
|
* <li>잔여 시간 만료 시 세션 무효화 후 {@code /login?expired=true}</li>
|
||||||
|
* <li>그 외에는 60초 스로틀로 lastAccessTime 갱신 (실제 활동 반영)</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SessionValidationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final String LAST_DB_UPDATE_ATTR = "SESSION_LAST_DB_UPDATE";
|
||||||
|
private static final long DB_UPDATE_THROTTLE_MS = 60_000; // 60초 스로틀링
|
||||||
|
|
||||||
|
private static final List<String> EXCLUDED_PREFIXES = Arrays.asList(
|
||||||
|
"/api/session/",
|
||||||
|
"/login",
|
||||||
|
"/actionLogin.do",
|
||||||
|
"/actionLogout.do",
|
||||||
|
"/css/",
|
||||||
|
"/js/",
|
||||||
|
"/img/",
|
||||||
|
"/plugins/",
|
||||||
|
"/fonts/",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/error",
|
||||||
|
"/health"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final UserSessionService userSessionService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
String contextPath = request.getContextPath();
|
||||||
|
if (contextPath != null && !contextPath.isEmpty()) {
|
||||||
|
path = path.substring(contextPath.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제외 경로 체크
|
||||||
|
if (isExcludedPath(path)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증되지 않은 요청은 통과
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpSession httpSession = request.getSession(false);
|
||||||
|
if (httpSession == null) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionId = httpSession.getId();
|
||||||
|
|
||||||
|
// 1. 강제 로그아웃 체크 (중복 로그인)
|
||||||
|
if (userSessionService.isForceLoggedOut(sessionId)) {
|
||||||
|
log.info("강제 로그아웃 감지 - sessionId: {}", sessionId);
|
||||||
|
userSessionService.removeSession(sessionId);
|
||||||
|
httpSession.invalidate();
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
response.sendRedirect(contextPath + "/login?forceLogout=true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 세션 만료 체크 (DB 레코드 없거나 잔여 0)
|
||||||
|
long remaining = userSessionService.getRemainingSeconds(sessionId);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
userSessionService.removeSession(sessionId);
|
||||||
|
httpSession.invalidate();
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
response.sendRedirect(contextPath + "/login?expired=true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. lastAccessTime 갱신 (60초 스로틀링)
|
||||||
|
Long lastUpdate = (Long) httpSession.getAttribute(LAST_DB_UPDATE_ATTR);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (lastUpdate == null || (now - lastUpdate) > DB_UPDATE_THROTTLE_MS) {
|
||||||
|
userSessionService.updateLastAccessTime(sessionId);
|
||||||
|
httpSession.setAttribute(LAST_DB_UPDATE_ATTR, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isExcludedPath(String path) {
|
||||||
|
for (String prefix : EXCLUDED_PREFIXES) {
|
||||||
|
if (path.startsWith(prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.eactive.apim.portal.apps.session.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
|
||||||
|
|
||||||
|
List<UserSession> findByLoginId(String loginId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
|
||||||
|
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId")
|
||||||
|
int forceLogoutAllSessions(@Param("loginId") String loginId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
|
||||||
|
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE UserSession s SET s.lastAccessTime = :now WHERE s.sessionId = :sessionId")
|
||||||
|
int updateLastAccessTime(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.eactive.apim.portal.apps.session.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||||
|
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserSessionService {
|
||||||
|
|
||||||
|
private static final String PROPERTY_GROUP = "Portal";
|
||||||
|
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
||||||
|
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
||||||
|
|
||||||
|
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
||||||
|
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
||||||
|
private static final String DEFAULT_KEEPALIVE_ENABLED = "false";
|
||||||
|
|
||||||
|
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
|
||||||
|
private static final long CLEANUP_MARGIN_MINUTES = 1;
|
||||||
|
|
||||||
|
private final UserSessionRepository userSessionRepository;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 세션 존재 여부 확인 (만료되지 않고 강제 로그아웃되지 않은 세션)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<UserSession> getActiveSession(String loginId) {
|
||||||
|
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||||
|
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);
|
||||||
|
|
||||||
|
List<UserSession> sessions = userSessionRepository.findByLoginId(loginId);
|
||||||
|
return sessions.stream()
|
||||||
|
.filter(s -> "N".equals(s.getForceLogout()))
|
||||||
|
.filter(s -> s.getLastAccessTime().isAfter(cutoff))
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 세션 등록
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void registerSession(String sessionId, String userId, String loginId, String ipAddress, String userAgent) {
|
||||||
|
UserSession session = UserSession.builder()
|
||||||
|
.sessionId(sessionId)
|
||||||
|
.userId(userId)
|
||||||
|
.loginId(loginId)
|
||||||
|
.loginTime(LocalDateTime.now())
|
||||||
|
.lastAccessTime(LocalDateTime.now())
|
||||||
|
.ipAddress(ipAddress)
|
||||||
|
.userAgent(truncate(userAgent, 500))
|
||||||
|
.forceLogout("N")
|
||||||
|
.build();
|
||||||
|
userSessionRepository.save(session);
|
||||||
|
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다른 세션 강제 로그아웃 플래그 설정 (중복 로그인 방지)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
|
||||||
|
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
|
||||||
|
if (count > 0) {
|
||||||
|
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 사용자의 모든 세션에 강제 로그아웃 플래그 설정.
|
||||||
|
* 관리자의 권한 변경(위임/회수/소속제외)을 대상 사용자의 활성 세션에 반영하기 위해 사용한다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void forceLogoutAllSessions(String loginId) {
|
||||||
|
int count = userSessionRepository.forceLogoutAllSessions(loginId);
|
||||||
|
if (count > 0) {
|
||||||
|
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 세션의 강제 로그아웃 여부 확인
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isForceLoggedOut(String sessionId) {
|
||||||
|
return userSessionRepository.findById(sessionId)
|
||||||
|
.map(s -> "Y".equals(s.getForceLogout()))
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 삭제
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void removeSession(String sessionId) {
|
||||||
|
if (userSessionRepository.existsById(sessionId)) {
|
||||||
|
userSessionRepository.deleteById(sessionId);
|
||||||
|
log.debug("세션 삭제 - sessionId: {}", sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 접근 시간 갱신 (세션 연장)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void updateLastAccessTime(String sessionId) {
|
||||||
|
userSessionRepository.updateLastAccessTime(sessionId, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
|
||||||
|
*/
|
||||||
|
public int getSessionTimeoutMinutes() {
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROPERTY_GROUP,
|
||||||
|
PROPERTY_NAME,
|
||||||
|
DEFAULT_TIMEOUT_MINUTES,
|
||||||
|
"세션 타임아웃 시간 (분)"
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
|
||||||
|
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB(PortalProperty)에서 세션 유지(타임아웃 무시) 기능 활성화 여부 조회.
|
||||||
|
* ⚠️ prod 가드는 포함하지 않는다 — prod 차단은 호출부(GlobalControllerAdvice)에서 {@code !prod && isKeepAliveEnabled()}로 처리.
|
||||||
|
*/
|
||||||
|
public boolean isKeepAliveEnabled() {
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROPERTY_GROUP,
|
||||||
|
KEEPALIVE_PROPERTY_NAME,
|
||||||
|
DEFAULT_KEEPALIVE_ENABLED,
|
||||||
|
"세션 유지(타임아웃 무시) 기능 활성화 여부 (true/false, 비운영 전용)"
|
||||||
|
);
|
||||||
|
return value != null && Boolean.parseBoolean(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long getRemainingSeconds(String sessionId) {
|
||||||
|
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||||
|
return userSessionRepository.findById(sessionId)
|
||||||
|
.map(s -> {
|
||||||
|
LocalDateTime expireTime = s.getLastAccessTime().plusMinutes(timeoutMinutes);
|
||||||
|
long remaining = Duration.between(LocalDateTime.now(), expireTime).getSeconds();
|
||||||
|
return Math.max(0, remaining);
|
||||||
|
})
|
||||||
|
.orElse(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 만료 세션 정리 (1분 주기).
|
||||||
|
* 논리적 만료(lastAccessTime + 타임아웃)가 지난 세션을 소량 마진({@value #CLEANUP_MARGIN_MINUTES}분) 후 삭제한다.
|
||||||
|
* heartbeat/활동이 멈춰 더 이상 갱신되지 않는 세션(닫힌 탭·크래시·네트워크 단절 등)을 신속히 제거한다.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRate = 60000)
|
||||||
|
@Transactional
|
||||||
|
public void cleanupExpiredSessions() {
|
||||||
|
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||||
|
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes + CLEANUP_MARGIN_MINUTES);
|
||||||
|
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String value, int maxLength) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.length() > maxLength ? value.substring(0, maxLength) : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-12
@@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.ResponseBody;
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 통계 Controller
|
* API 통계 Controller.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Controller
|
@Controller
|
||||||
@@ -31,7 +31,7 @@ public class ApiStatisticsController {
|
|||||||
private final ApiStatisticsService apiStatisticsService;
|
private final ApiStatisticsService apiStatisticsService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 페이지 진입
|
* 통계 페이지 진입.
|
||||||
*/
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public String statisticsPage(Model model) {
|
public String statisticsPage(Model model) {
|
||||||
@@ -41,11 +41,12 @@ public class ApiStatisticsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String orgId = org.getId();
|
String orgId = org.getId();
|
||||||
|
log.debug("[통계] 페이지 진입 - orgId={}", orgId);
|
||||||
|
|
||||||
// 앱 목록 조회 (선택용)
|
// 앱 목록 조회 (선택용)
|
||||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||||
|
|
||||||
// 기본 조회 (7일 전 ~ 오늘, 전체 앱)
|
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
||||||
searchDto.setEndDate(LocalDate.now());
|
searchDto.setEndDate(LocalDate.now());
|
||||||
@@ -54,13 +55,19 @@ public class ApiStatisticsController {
|
|||||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||||
model.addAttribute("summary", result.getSummary());
|
model.addAttribute("summary", result.getSummary());
|
||||||
model.addAttribute("details", result.getDetails());
|
model.addAttribute("details", result.getDetails());
|
||||||
|
model.addAttribute("periods", result.getPeriods());
|
||||||
model.addAttribute("searchDto", searchDto);
|
model.addAttribute("searchDto", searchDto);
|
||||||
|
|
||||||
|
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||||
|
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||||
|
model.addAttribute("statsAggregationMinute", apiStatisticsService.getAggregationMinute());
|
||||||
|
model.addAttribute("statsLatestTime", apiStatisticsService.getLatestStatTime(orgId));
|
||||||
|
|
||||||
return "apps/statistics/apiStatistics";
|
return "apps/statistics/apiStatistics";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 조회 (AJAX)
|
* 통계 조회 (AJAX).
|
||||||
*/
|
*/
|
||||||
@PostMapping("/search")
|
@PostMapping("/search")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
@@ -70,11 +77,12 @@ public class ApiStatisticsController {
|
|||||||
return ResponseEntity.status(401).build();
|
return ResponseEntity.status(401).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 날짜 범위 검증 (최대 40일)
|
log.debug("[통계] 검색 요청(AJAX) - orgId={}, mode={}, clientId={}",
|
||||||
if (!searchDto.isValidDateRange()) {
|
org.getId(), searchDto.getMode(), searchDto.getClientId());
|
||||||
|
|
||||||
|
if (!isValidRange(searchDto)) {
|
||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.badRequest()
|
||||||
.body(java.util.Collections.singletonMap("error",
|
.body(java.util.Collections.singletonMap("error", rangeErrorMessage(searchDto)));
|
||||||
"조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String orgId = org.getId();
|
String orgId = org.getId();
|
||||||
@@ -83,7 +91,9 @@ public class ApiStatisticsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV 다운로드
|
* CSV 다운로드.
|
||||||
|
*
|
||||||
|
* ※ UI에서 다운로드 버튼은 제거되었으나 백엔드 기능은 유지한다(직접 호출용).
|
||||||
*/
|
*/
|
||||||
@GetMapping("/download")
|
@GetMapping("/download")
|
||||||
public void downloadCsv(ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
|
public void downloadCsv(ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
|
||||||
@@ -93,9 +103,8 @@ public class ApiStatisticsController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 날짜 범위 검증 (최대 40일)
|
if (!isValidRange(searchDto)) {
|
||||||
if (!searchDto.isValidDateRange()) {
|
response.sendError(400, rangeErrorMessage(searchDto));
|
||||||
response.sendError(400, "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +112,16 @@ public class ApiStatisticsController {
|
|||||||
apiStatisticsService.downloadCsv(orgId, searchDto, response);
|
apiStatisticsService.downloadCsv(orgId, searchDto, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isValidRange(ApiStatisticsSearchDto searchDto) {
|
||||||
|
return searchDto.isMonthly() ? searchDto.isValidMonth() : searchDto.isValidDateRange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||||
|
return searchDto.isMonthly()
|
||||||
|
? "조회할 월이 올바르지 않습니다."
|
||||||
|
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
||||||
|
}
|
||||||
|
|
||||||
private PortalOrg getPortalOrg() {
|
private PortalOrg getPortalOrg() {
|
||||||
if (SecurityUtil.getPortalAuthenticatedUser() != null) {
|
if (SecurityUtil.getPortalAuthenticatedUser() != null) {
|
||||||
return SecurityUtil.getPortalAuthenticatedUser().getPortalOrg();
|
return SecurityUtil.getPortalAuthenticatedUser().getPortalOrg();
|
||||||
|
|||||||
+26
-24
@@ -5,50 +5,52 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개별 API 통계 상세 DTO
|
* 개별 API 통계 상세 DTO (API_NAME 단위 집계).
|
||||||
|
* 성공/타임아웃/실패(오류) 배타 구분. {@link ApiStatisticsSummaryDto} 참고.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class ApiStatisticsDetailDto {
|
public class ApiStatisticsDetailDto {
|
||||||
|
|
||||||
private String apiId;
|
|
||||||
private String apiName;
|
private String apiName;
|
||||||
private Long attemptedCount; // 호출 건
|
private Long totalCount; // 호출 건
|
||||||
private Long completedCount; // 성공 건
|
private Long successCount; // 성공 건
|
||||||
private Long failedCount; // 실패 건
|
private Long timeoutCount; // 타임아웃 건
|
||||||
|
private Long errorCount; // 실패(오류) 건
|
||||||
private Double successRate; // 성공율 (%)
|
private Double successRate; // 성공율 (%)
|
||||||
private Double failureRate; // 실패율 (%)
|
private Double timeoutRate; // 타임아웃율 (%)
|
||||||
|
private Double errorRate; // 실패(오류)율 (%)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository 조회용 생성자
|
* Repository 조회용 생성자.
|
||||||
*/
|
*/
|
||||||
public ApiStatisticsDetailDto(String apiId, String apiName, Long attemptedCount, Long completedCount) {
|
public ApiStatisticsDetailDto(String apiName, Long totalCount, Long successCount,
|
||||||
this.apiId = apiId;
|
Long timeoutCount, Long systemErrCount, Long bizErrCount) {
|
||||||
this.apiName = apiName;
|
this.apiName = apiName;
|
||||||
this.attemptedCount = attemptedCount != null ? attemptedCount : 0L;
|
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||||
this.completedCount = completedCount != null ? completedCount : 0L;
|
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||||
|
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||||
|
this.errorCount = ApiStatisticsSummaryDto.nz(systemErrCount) + ApiStatisticsSummaryDto.nz(bizErrCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실패건, 성공률, 실패율 계산
|
* 성공율/타임아웃율/실패율 계산.
|
||||||
*/
|
*/
|
||||||
public void calculateRates() {
|
public void calculateRates() {
|
||||||
if (attemptedCount == null) {
|
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||||
attemptedCount = 0L;
|
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||||
}
|
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||||
if (completedCount == null) {
|
this.errorCount = ApiStatisticsSummaryDto.nz(errorCount);
|
||||||
completedCount = 0L;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.failedCount = attemptedCount - completedCount;
|
if (totalCount > 0) {
|
||||||
|
this.successRate = ApiStatisticsSummaryDto.rate(successCount, totalCount);
|
||||||
if (attemptedCount > 0) {
|
this.timeoutRate = ApiStatisticsSummaryDto.rate(timeoutCount, totalCount);
|
||||||
this.successRate = Math.round((completedCount * 1000.0) / attemptedCount) / 10.0;
|
this.errorRate = ApiStatisticsSummaryDto.rate(errorCount, totalCount);
|
||||||
this.failureRate = Math.round((failedCount * 1000.0) / attemptedCount) / 10.0;
|
|
||||||
} else {
|
} else {
|
||||||
this.successRate = 0.0;
|
this.successRate = 0.0;
|
||||||
this.failureRate = 0.0;
|
this.timeoutRate = 0.0;
|
||||||
|
this.errorRate = 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.eactive.apim.portal.apps.statistics.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기간별(일별/월별) 누적 통계 DTO — API별 통계 하단 표에 사용.
|
||||||
|
*
|
||||||
|
* period 는 일별 모드에서 "yyyy-MM-dd", 월별 모드에서 "yyyy-MM" 형태의 라벨.
|
||||||
|
* 각 행은 해당 기간의 전체 앱/전체 API 합계이다.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiStatisticsPeriodDto {
|
||||||
|
|
||||||
|
private String period; // 기간 라벨 (yyyy-MM-dd 또는 yyyy-MM)
|
||||||
|
private Long totalCount;
|
||||||
|
private Long successCount;
|
||||||
|
private Long timeoutCount;
|
||||||
|
private Long errorCount;
|
||||||
|
private Double successRate;
|
||||||
|
private Double timeoutRate;
|
||||||
|
private Double errorRate;
|
||||||
|
|
||||||
|
/** 해당 일자에 실제 집계 데이터가 존재하는지 여부. false면 표에 '-'로 표기(0과 구분). */
|
||||||
|
private boolean hasData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository 조회용 생성자 (실제 데이터 행 → hasData=true).
|
||||||
|
*/
|
||||||
|
public ApiStatisticsPeriodDto(String period, Long totalCount, Long successCount,
|
||||||
|
Long timeoutCount, Long systemErrCount, Long bizErrCount) {
|
||||||
|
this.period = period;
|
||||||
|
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||||
|
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||||
|
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||||
|
this.errorCount = ApiStatisticsSummaryDto.nz(systemErrCount) + ApiStatisticsSummaryDto.nz(bizErrCount);
|
||||||
|
this.hasData = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공율/타임아웃율/실패율 계산.
|
||||||
|
*/
|
||||||
|
public void calculateRates() {
|
||||||
|
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||||
|
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||||
|
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||||
|
this.errorCount = ApiStatisticsSummaryDto.nz(errorCount);
|
||||||
|
|
||||||
|
if (totalCount > 0) {
|
||||||
|
this.successRate = ApiStatisticsSummaryDto.rate(successCount, totalCount);
|
||||||
|
this.timeoutRate = ApiStatisticsSummaryDto.rate(timeoutCount, totalCount);
|
||||||
|
this.errorRate = ApiStatisticsSummaryDto.rate(errorCount, totalCount);
|
||||||
|
} else {
|
||||||
|
this.successRate = 0.0;
|
||||||
|
this.timeoutRate = 0.0;
|
||||||
|
this.errorRate = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -7,7 +7,8 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 통계 조회 결과 DTO
|
* API 통계 조회 결과 DTO.
|
||||||
|
* summary: 전체 요약, details: API별, periods: 기간별(일/월) 누적.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@@ -16,13 +17,15 @@ public class ApiStatisticsResultDto {
|
|||||||
|
|
||||||
private ApiStatisticsSummaryDto summary;
|
private ApiStatisticsSummaryDto summary;
|
||||||
private List<ApiStatisticsDetailDto> details;
|
private List<ApiStatisticsDetailDto> details;
|
||||||
|
private List<ApiStatisticsPeriodDto> periods;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 빈 결과 생성
|
* 빈 결과 생성.
|
||||||
*/
|
*/
|
||||||
public static ApiStatisticsResultDto empty() {
|
public static ApiStatisticsResultDto empty() {
|
||||||
return new ApiStatisticsResultDto(
|
return new ApiStatisticsResultDto(
|
||||||
ApiStatisticsSummaryDto.empty(),
|
ApiStatisticsSummaryDto.empty(),
|
||||||
|
new ArrayList<>(),
|
||||||
new ArrayList<>()
|
new ArrayList<>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-10
@@ -6,16 +6,25 @@ import lombok.Data;
|
|||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 통계 검색 조건 DTO
|
* API 통계 검색 조건 DTO.
|
||||||
|
*
|
||||||
|
* mode = DAILY 이면 startDate~endDate(날짜범위, API_STATS_HOUR),
|
||||||
|
* mode = MONTHLY 이면 month(YYYYMM 단일 선택)를 사용한다.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class ApiStatisticsSearchDto {
|
public class ApiStatisticsSearchDto {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최대 조회 가능 일수
|
* 최대 조회 가능 일수 (일별 모드).
|
||||||
*/
|
*/
|
||||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||||
|
|
||||||
|
public enum Mode {
|
||||||
|
DAILY, MONTHLY
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mode mode = Mode.DAILY;
|
||||||
|
|
||||||
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
||||||
private LocalDate startDate;
|
private LocalDate startDate;
|
||||||
|
|
||||||
@@ -23,7 +32,12 @@ public class ApiStatisticsSearchDto {
|
|||||||
private LocalDate endDate;
|
private LocalDate endDate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선택된 앱의 clientId (null 또는 빈값이면 전체 앱)
|
* 월별 조회 대상 월 (YYYYMM, 단일 선택).
|
||||||
|
*/
|
||||||
|
private String month;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택된 앱의 clientId (null 또는 빈값이면 전체 앱).
|
||||||
*/
|
*/
|
||||||
private String clientId;
|
private String clientId;
|
||||||
|
|
||||||
@@ -33,15 +47,19 @@ public class ApiStatisticsSearchDto {
|
|||||||
this.clientId = null;
|
this.clientId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isMonthly() {
|
||||||
|
return mode == Mode.MONTHLY;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 앱이 선택되었는지 여부
|
* 특정 앱이 선택되었는지 여부.
|
||||||
*/
|
*/
|
||||||
public boolean hasSelectedApp() {
|
public boolean hasSelectedApp() {
|
||||||
return clientId != null && !clientId.trim().isEmpty();
|
return clientId != null && !clientId.trim().isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 날짜 범위가 유효한지 검증 (최대 40일)
|
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
||||||
*/
|
*/
|
||||||
public boolean isValidDateRange() {
|
public boolean isValidDateRange() {
|
||||||
if (startDate == null || endDate == null) {
|
if (startDate == null || endDate == null) {
|
||||||
@@ -55,12 +73,13 @@ public class ApiStatisticsSearchDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조회 기간 일수 반환
|
* 월(월별) 선택값이 YYYYMM 형식인지 검증 (가용월 여부는 서비스/드롭다운에서 제한).
|
||||||
*/
|
*/
|
||||||
public long getDateRangeDays() {
|
public boolean isValidMonth() {
|
||||||
if (startDate == null || endDate == null) {
|
return isYyyymm(month);
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
return ChronoUnit.DAYS.between(startDate, endDate);
|
|
||||||
|
private static boolean isYyyymm(String s) {
|
||||||
|
return s != null && s.matches("^\\d{6}$");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-24
@@ -5,54 +5,72 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 전체 통계 요약 DTO
|
* API 전체 통계 요약 DTO.
|
||||||
|
*
|
||||||
|
* 집계 소스: AGWAPP.API_STATS_HOUR / API_STATS_MONTH.
|
||||||
|
* 성공/타임아웃/실패(오류)를 배타적으로 구분한다.
|
||||||
|
* - 성공 = SUCCESS_CNT
|
||||||
|
* - 타임아웃 = TIMEOUT_CNT
|
||||||
|
* - 실패(오류) = SYSTEM_ERR_CNT + BIZ_ERR_CNT
|
||||||
|
* - 호출수(total) = TOTAL_CNT
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class ApiStatisticsSummaryDto {
|
public class ApiStatisticsSummaryDto {
|
||||||
|
|
||||||
private Long totalAttempted; // 전체 호출 건
|
private Long totalCount; // 전체 호출 건 (TOTAL_CNT)
|
||||||
private Long totalCompleted; // 전체 성공 건
|
private Long successCount; // 성공 건 (SUCCESS_CNT)
|
||||||
private Long totalFailed; // 전체 실패 건
|
private Long timeoutCount; // 타임아웃 건 (TIMEOUT_CNT)
|
||||||
|
private Long errorCount; // 실패(오류) 건 (SYSTEM_ERR_CNT + BIZ_ERR_CNT)
|
||||||
private Double successRate; // 성공율 (%)
|
private Double successRate; // 성공율 (%)
|
||||||
private Double failureRate; // 실패율 (%)
|
private Double timeoutRate; // 타임아웃율 (%)
|
||||||
|
private Double errorRate; // 실패(오류)율 (%)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository 조회용 생성자 (attempted, completed만)
|
* Repository 조회용 생성자 (SUM 집계값).
|
||||||
*/
|
*/
|
||||||
public ApiStatisticsSummaryDto(Long totalAttempted, Long totalCompleted) {
|
public ApiStatisticsSummaryDto(Long totalCount, Long successCount, Long timeoutCount,
|
||||||
this.totalAttempted = totalAttempted != null ? totalAttempted : 0L;
|
Long systemErrCount, Long bizErrCount) {
|
||||||
this.totalCompleted = totalCompleted != null ? totalCompleted : 0L;
|
this.totalCount = nz(totalCount);
|
||||||
|
this.successCount = nz(successCount);
|
||||||
|
this.timeoutCount = nz(timeoutCount);
|
||||||
|
this.errorCount = nz(systemErrCount) + nz(bizErrCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실패건, 성공률, 실패율 계산
|
* 성공률/타임아웃율/실패율 계산.
|
||||||
*/
|
*/
|
||||||
public void calculateRates() {
|
public void calculateRates() {
|
||||||
if (totalAttempted == null) {
|
this.totalCount = nz(totalCount);
|
||||||
totalAttempted = 0L;
|
this.successCount = nz(successCount);
|
||||||
}
|
this.timeoutCount = nz(timeoutCount);
|
||||||
if (totalCompleted == null) {
|
this.errorCount = nz(errorCount);
|
||||||
totalCompleted = 0L;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.totalFailed = totalAttempted - totalCompleted;
|
if (totalCount > 0) {
|
||||||
|
this.successRate = rate(successCount, totalCount);
|
||||||
if (totalAttempted > 0) {
|
this.timeoutRate = rate(timeoutCount, totalCount);
|
||||||
this.successRate = Math.round((totalCompleted * 1000.0) / totalAttempted) / 10.0;
|
this.errorRate = rate(errorCount, totalCount);
|
||||||
this.failureRate = Math.round((totalFailed * 1000.0) / totalAttempted) / 10.0;
|
|
||||||
} else {
|
} else {
|
||||||
this.successRate = 0.0;
|
this.successRate = 0.0;
|
||||||
this.failureRate = 0.0;
|
this.timeoutRate = 0.0;
|
||||||
|
this.errorRate = 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static long nz(Long v) {
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double rate(long part, long total) {
|
||||||
|
return Math.round((part * 1000.0) / total) / 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 빈 통계 생성
|
* 빈 통계 생성.
|
||||||
*/
|
*/
|
||||||
public static ApiStatisticsSummaryDto empty() {
|
public static ApiStatisticsSummaryDto empty() {
|
||||||
ApiStatisticsSummaryDto dto = new ApiStatisticsSummaryDto(0L, 0L);
|
ApiStatisticsSummaryDto dto = new ApiStatisticsSummaryDto(0L, 0L, 0L, 0L, 0L);
|
||||||
dto.calculateRates();
|
dto.calculateRates();
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|||||||
-81
@@ -1,81 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.statistics.repository;
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
|
||||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
|
||||||
import com.eactive.apim.portal.obp.entity.ObpGwMetric;
|
|
||||||
import com.eactive.eai.data.jpa.BaseRepository;
|
|
||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API 통계 Repository
|
|
||||||
* ObpGwMetric 테이블에서 통계 데이터를 조회
|
|
||||||
*/
|
|
||||||
@EMSDataSource
|
|
||||||
public interface ApiStatisticsRepository extends BaseRepository<ObpGwMetric, String> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 통계 조회 (특정 기간, 특정 조직의 모든 앱)
|
|
||||||
*/
|
|
||||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
|
||||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
|
||||||
"FROM ObpGwMetric m " +
|
|
||||||
"WHERE m.orgId = :orgId " +
|
|
||||||
"AND m.timeslice BETWEEN :startTime AND :endTime")
|
|
||||||
ApiStatisticsSummaryDto findSummaryByOrgAndPeriod(
|
|
||||||
@Param("orgId") String orgId,
|
|
||||||
@Param("startTime") LocalDateTime startTime,
|
|
||||||
@Param("endTime") LocalDateTime endTime);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 통계 조회 (특정 기간, 특정 앱)
|
|
||||||
*/
|
|
||||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
|
||||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
|
||||||
"FROM ObpGwMetric m " +
|
|
||||||
"WHERE m.orgId = :orgId " +
|
|
||||||
"AND m.clientId = :clientId " +
|
|
||||||
"AND m.timeslice BETWEEN :startTime AND :endTime")
|
|
||||||
ApiStatisticsSummaryDto findSummaryByOrgAndApp(
|
|
||||||
@Param("orgId") String orgId,
|
|
||||||
@Param("clientId") String clientId,
|
|
||||||
@Param("startTime") LocalDateTime startTime,
|
|
||||||
@Param("endTime") LocalDateTime endTime);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개별 API 통계 조회 (특정 기간, 특정 조직의 모든 앱)
|
|
||||||
*/
|
|
||||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
|
||||||
"m.apiId, m.apiName, " +
|
|
||||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
|
||||||
"FROM ObpGwMetric m " +
|
|
||||||
"WHERE m.orgId = :orgId " +
|
|
||||||
"AND m.timeslice BETWEEN :startTime AND :endTime " +
|
|
||||||
"GROUP BY m.apiId, m.apiName " +
|
|
||||||
"ORDER BY SUM(m.attemptedCount) DESC")
|
|
||||||
List<ApiStatisticsDetailDto> findDetailByOrgAndPeriod(
|
|
||||||
@Param("orgId") String orgId,
|
|
||||||
@Param("startTime") LocalDateTime startTime,
|
|
||||||
@Param("endTime") LocalDateTime endTime);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개별 API 통계 조회 (특정 기간, 특정 앱)
|
|
||||||
*/
|
|
||||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
|
||||||
"m.apiId, m.apiName, " +
|
|
||||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
|
||||||
"FROM ObpGwMetric m " +
|
|
||||||
"WHERE m.orgId = :orgId " +
|
|
||||||
"AND m.clientId = :clientId " +
|
|
||||||
"AND m.timeslice BETWEEN :startTime AND :endTime " +
|
|
||||||
"GROUP BY m.apiId, m.apiName " +
|
|
||||||
"ORDER BY SUM(m.attemptedCount) DESC")
|
|
||||||
List<ApiStatisticsDetailDto> findDetailByOrgAndApp(
|
|
||||||
@Param("orgId") String orgId,
|
|
||||||
@Param("clientId") String clientId,
|
|
||||||
@Param("startTime") LocalDateTime startTime,
|
|
||||||
@Param("endTime") LocalDateTime endTime);
|
|
||||||
}
|
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.eactive.apim.portal.apps.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.statistics.repository.entity.JobInfo;
|
||||||
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TSEAIRM21(스케줄러 잡) 조회 — 통계 집계 크론 추출용.
|
||||||
|
*/
|
||||||
|
@EMSDataSource
|
||||||
|
public interface JobInfoRepository extends JpaRepository<JobInfo, String> {
|
||||||
|
|
||||||
|
Optional<JobInfo> findByJobname(String jobname);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.eactive.apim.portal.apps.statistics.repository.entity;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄러 잡 정보(EMSAPP.TSEAIRM21) — 통계 집계 크론 조회 전용.
|
||||||
|
*
|
||||||
|
* 안내 문구의 "매시 N분 집계" 값을 실제 크론(CRONEXP)에서 추출하기 위해 JOBNAME/CRONEXP 두 컬럼만 매핑한다.
|
||||||
|
* (읽기 전용. 잡 실행/스케줄 변경은 eapim-admin 소관)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "TSEAIRM21")
|
||||||
|
public class JobInfo {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "JOBNAME")
|
||||||
|
private String jobname;
|
||||||
|
|
||||||
|
@Column(name = "CRONEXP")
|
||||||
|
private String cronexp;
|
||||||
|
}
|
||||||
+424
-66
@@ -1,21 +1,37 @@
|
|||||||
package com.eactive.apim.portal.apps.statistics.service;
|
package com.eactive.apim.portal.apps.statistics.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.app.entity.Credential;
|
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
|
||||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsMonthRepository;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.repository.GwSvcInfoRepository;
|
||||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsPeriodDto;
|
||||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
|
||||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSearchDto;
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSearchDto;
|
||||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||||
import com.eactive.apim.portal.apps.statistics.repository.ApiStatisticsRepository;
|
import com.eactive.apim.portal.apps.statistics.repository.JobInfoRepository;
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.GwAuthClient;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.GwSvcInfo;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.OutputStreamWriter;
|
import java.io.OutputStreamWriter;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -23,7 +39,10 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 통계 Service
|
* API 통계 Service.
|
||||||
|
*
|
||||||
|
* 데이터 소스: Gateway DB(AGWAPP) API_STATS_HOUR(일별) / API_STATS_MONTH(월별).
|
||||||
|
* 통계 테이블에 org_id 가 없으므로 org 소속 앱의 CLIENT_ID 목록으로 필터한다.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -31,79 +50,419 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class ApiStatisticsService {
|
public class ApiStatisticsService {
|
||||||
|
|
||||||
private final CredentialRepository credentialRepository;
|
/** 시간별 집계 스케줄러 잡명 기본값 — PTL_PROPERTY(Portal/{@value #JOB_NAME_PROP})로 재정의 가능. */
|
||||||
private final ApiStatisticsRepository apiStatisticsRepository;
|
private static final String DEFAULT_HOURLY_JOB_NAME = "ApiStatsHourlyAggregationJob";
|
||||||
|
private static final String PROP_GROUP = "Portal";
|
||||||
|
private static final String JOB_NAME_PROP = "stats.hourly.aggregation.job.name";
|
||||||
|
|
||||||
|
private static final DateTimeFormatter LATEST_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH시");
|
||||||
|
private static final DateTimeFormatter YYYYMM_FMT = DateTimeFormatter.ofPattern("yyyyMM");
|
||||||
|
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
|
/** 요일 한글 한 글자 (월요일=index 0). */
|
||||||
|
private static final String[] WEEKDAYS = {"월", "화", "수", "목", "금", "토", "일"};
|
||||||
|
|
||||||
|
private final GwAuthClientRepository gwAuthClientRepository;
|
||||||
|
private final GwSvcInfoRepository gwSvcInfoRepository;
|
||||||
|
private final ApiStatsHourRepository apiStatsHourRepository;
|
||||||
|
private final ApiStatsDayRepository apiStatsDayRepository;
|
||||||
|
private final ApiStatsMonthRepository apiStatsMonthRepository;
|
||||||
|
private final JobInfoRepository jobInfoRepository;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인 사용자 법인의 앱 목록 조회 (전체)
|
* 로그인 사용자 법인의 앱 목록 조회 (전체). TSEAIAU01.ORGID 기준.
|
||||||
|
* 드롭다운 값(CLIENTID)이 API_STATS_*.CLIENT_ID 와 동일해야 하므로 게이트웨이 인증 클라이언트를 소스로 한다.
|
||||||
*/
|
*/
|
||||||
public List<Credential> getAppListByOrg(String orgId) {
|
public List<GwAuthClient> getAppListByOrg(String orgId) {
|
||||||
return credentialRepository.findAllByOrgid(orgId);
|
return gwAuthClientRepository.findByOrgIdOrderByClientNameAsc(orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 조회
|
* 조회 대상 clientId 목록. 특정 앱 선택 시 org 소속 검증 후 해당 1건, 아니면 org 전체 앱.
|
||||||
|
* org→clientId 는 TSEAIAU01.ORGID(=PTL_ORG.ID) 로 조회한다.
|
||||||
|
*/
|
||||||
|
private List<String> resolveClientIds(String orgId, ApiStatisticsSearchDto searchDto) {
|
||||||
|
List<String> orgClientIds = orgClientIds(orgId);
|
||||||
|
if (searchDto.hasSelectedApp()) {
|
||||||
|
String clientId = searchDto.getClientId();
|
||||||
|
return orgClientIds.contains(clientId) ? Collections.singletonList(clientId) : Collections.emptyList();
|
||||||
|
}
|
||||||
|
return orgClientIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 조회 (일별/월별 모드 분기).
|
||||||
*/
|
*/
|
||||||
public ApiStatisticsResultDto getStatistics(String orgId, ApiStatisticsSearchDto searchDto) {
|
public ApiStatisticsResultDto getStatistics(String orgId, ApiStatisticsSearchDto searchDto) {
|
||||||
LocalDateTime startTime = searchDto.getStartDate().atStartOfDay();
|
log.debug("[통계] 조회 시작 - orgId={}, mode={}, clientId={}, 일별[{}~{}], 월별[{}]",
|
||||||
LocalDateTime endTime = searchDto.getEndDate().atTime(LocalTime.MAX);
|
orgId, searchDto.getMode(), searchDto.getClientId(),
|
||||||
|
searchDto.getStartDate(), searchDto.getEndDate(), searchDto.getMonth());
|
||||||
|
|
||||||
ApiStatisticsSummaryDto summary;
|
List<String> clientIds = resolveClientIds(orgId, searchDto);
|
||||||
List<ApiStatisticsDetailDto> details;
|
log.debug("[통계] org 스코핑 clientId {}건 - {}", clientIds.size(), clientIds);
|
||||||
|
if (clientIds.isEmpty()) {
|
||||||
if (searchDto.hasSelectedApp()) {
|
log.debug("[통계] 대상 clientId 없음 - 빈 결과 반환 (orgId={})", orgId);
|
||||||
// 특정 앱 통계
|
|
||||||
String clientId = searchDto.getClientId();
|
|
||||||
|
|
||||||
// 해당 앱이 조직 소속인지 검증
|
|
||||||
if (!isAppBelongsToOrg(clientId, orgId)) {
|
|
||||||
return ApiStatisticsResultDto.empty();
|
return ApiStatisticsResultDto.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
summary = apiStatisticsRepository.findSummaryByOrgAndApp(
|
ApiStatisticsSummaryDto summary;
|
||||||
orgId, clientId, startTime, endTime);
|
List<ApiStatisticsDetailDto> details;
|
||||||
details = apiStatisticsRepository.findDetailByOrgAndApp(
|
List<ApiStatisticsPeriodDto> periods;
|
||||||
orgId, clientId, startTime, endTime);
|
|
||||||
|
if (searchDto.isMonthly()) {
|
||||||
|
String month = searchDto.getMonth();
|
||||||
|
String currentYm = YearMonth.now().format(YYYYMM_FMT);
|
||||||
|
YearMonth ym = YearMonth.parse(month, YYYYMM_FMT);
|
||||||
|
boolean isCurrent = month.equals(currentYm);
|
||||||
|
LocalDate monthStart = ym.atDay(1);
|
||||||
|
LocalDate monthEnd = isCurrent ? LocalDate.now() : ym.atEndOfMonth();
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
// 현재 월: DAY(과거일) + HOUR(오늘) 합산. 하단 일별표도 동일 소스.
|
||||||
|
log.debug("[통계] 월별-현재월 {} → DAY+오늘(HOUR) {}~{}", month, monthStart, monthEnd);
|
||||||
|
RangeStats rs = combineDayAndToday(clientIds, monthStart, monthEnd);
|
||||||
|
summary = rs.summary;
|
||||||
|
details = rs.details;
|
||||||
|
periods = rs.periods;
|
||||||
} else {
|
} else {
|
||||||
// 전체 앱 통계
|
// 과거 월: 요약/API별은 API_STATS_MONTH, 하단 일별표는 해당 월의 DAY 일자별
|
||||||
summary = apiStatisticsRepository.findSummaryByOrgAndPeriod(
|
log.debug("[통계] 월별-과거월 {} → API_STATS_MONTH + 하단 DAY 일별", month);
|
||||||
orgId, startTime, endTime);
|
summary = orEmpty(apiStatsMonthRepository.findSummary(clientIds, month, month));
|
||||||
details = apiStatisticsRepository.findDetailByOrgAndPeriod(
|
details = nvl(apiStatsMonthRepository.findDetail(clientIds, month, month));
|
||||||
orgId, startTime, endTime);
|
List<Object[]> monthlyDayRows = apiStatsDayRepository.findPeriodByDay(clientIds, monthStart, monthEnd);
|
||||||
|
periods = buildDailyPeriods(monthlyDayRows, monthStart, monthEnd);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일별 조회: DAY(과거일) + HOUR(오늘) 합산. (HOUR는 보존 짧음/오늘만, DAY는 과거 보존)
|
||||||
|
LocalDate start = searchDto.getStartDate();
|
||||||
|
LocalDate end = searchDto.getEndDate();
|
||||||
|
log.debug("[통계] 일별 조회 - DAY+오늘(HOUR), 범위 {}~{}", start, end);
|
||||||
|
RangeStats rs = combineDayAndToday(clientIds, start, end);
|
||||||
|
summary = rs.summary;
|
||||||
|
details = rs.details;
|
||||||
|
periods = rs.periods;
|
||||||
}
|
}
|
||||||
|
|
||||||
// null 체크 및 빈 결과 처리
|
|
||||||
if (summary == null) {
|
if (summary == null) {
|
||||||
summary = ApiStatisticsSummaryDto.empty();
|
summary = ApiStatisticsSummaryDto.empty();
|
||||||
} else {
|
} else {
|
||||||
summary.calculateRates();
|
summary.calculateRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (details == null) {
|
if (details == null) {
|
||||||
details = Collections.emptyList();
|
details = Collections.emptyList();
|
||||||
} else {
|
} else {
|
||||||
details.forEach(ApiStatisticsDetailDto::calculateRates);
|
details.forEach(ApiStatisticsDetailDto::calculateRates);
|
||||||
}
|
}
|
||||||
|
if (periods == null) {
|
||||||
|
periods = Collections.emptyList();
|
||||||
|
} else {
|
||||||
|
periods.forEach(ApiStatisticsPeriodDto::calculateRates);
|
||||||
|
}
|
||||||
|
|
||||||
return new ApiStatisticsResultDto(summary, details);
|
// API_NAME(인터페이스 ID) → 표시명(TSEAIHE01.EAISVCDESC) 매핑
|
||||||
|
applyDisplayNames(details);
|
||||||
|
|
||||||
|
log.debug("[통계] 조회 완료 - 총호출={}, 성공={}, 타임아웃={}, 실패={} / API별 {}건, 기간별 {}건",
|
||||||
|
summary.getTotalCount(), summary.getSuccessCount(), summary.getTimeoutCount(),
|
||||||
|
summary.getErrorCount(), details.size(), periods.size());
|
||||||
|
|
||||||
|
return new ApiStatisticsResultDto(summary, details, periods);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앱이 해당 조직 소속인지 검증
|
* 일별 기간표 Object[] row → ApiStatisticsPeriodDto.
|
||||||
|
* row: [0]=일자(String), [1]=total, [2]=success, [3]=timeout, [4]=systemErr, [5]=biz
|
||||||
*/
|
*/
|
||||||
private boolean isAppBelongsToOrg(String clientId, String orgId) {
|
private static ApiStatisticsPeriodDto toPeriodDto(Object[] row) {
|
||||||
return credentialRepository.findByClientidAndOrgid(clientId, orgId).isPresent();
|
return new ApiStatisticsPeriodDto(
|
||||||
|
row[0] != null ? row[0].toString() : null,
|
||||||
|
toLong(row[1]), toLong(row[2]), toLong(row[3]), toLong(row[4]), toLong(row[5]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long toLong(Object o) {
|
||||||
|
return (o instanceof Number) ? ((Number) o).longValue() : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV 다운로드
|
* 지정 날짜 범위 [start,end] 의 통계를 조립한다.
|
||||||
|
* 과거일은 API_STATS_DAY, "오늘"은 아직 DAY 미집계이므로 API_STATS_HOUR 에서 합산해 더한다.
|
||||||
|
*/
|
||||||
|
private RangeStats combineDayAndToday(List<String> clientIds, LocalDate start, LocalDate end) {
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
boolean includesToday = !today.isBefore(start) && !today.isAfter(end);
|
||||||
|
LocalDate dayEnd = includesToday ? today.minusDays(1) : end;
|
||||||
|
|
||||||
|
ApiStatisticsSummaryDto summary = ApiStatisticsSummaryDto.empty();
|
||||||
|
List<ApiStatisticsDetailDto> details = new ArrayList<>();
|
||||||
|
Map<String, ApiStatisticsPeriodDto> byDate = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
// 과거일: API_STATS_DAY [start .. dayEnd]
|
||||||
|
if (!start.isAfter(dayEnd)) {
|
||||||
|
summary = orEmpty(apiStatsDayRepository.findSummary(clientIds, start, dayEnd));
|
||||||
|
details = nvl(apiStatsDayRepository.findDetail(clientIds, start, dayEnd));
|
||||||
|
byDate = rowsToMap(apiStatsDayRepository.findPeriodByDay(clientIds, start, dayEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오늘: API_STATS_HOUR (익일 DAY 집계 전)
|
||||||
|
if (includesToday) {
|
||||||
|
LocalDateTime ts = today.atStartOfDay();
|
||||||
|
LocalDateTime te = today.atTime(LocalTime.MAX);
|
||||||
|
ApiStatisticsSummaryDto todaySummary = orEmpty(apiStatsHourRepository.findSummary(clientIds, ts, te));
|
||||||
|
List<ApiStatisticsDetailDto> todayDetails = nvl(apiStatsHourRepository.findDetail(clientIds, ts, te));
|
||||||
|
summary = mergeSummaries(summary, todaySummary);
|
||||||
|
details = mergeDetails(details, todayDetails);
|
||||||
|
if (nz(todaySummary.getTotalCount()) > 0) {
|
||||||
|
ApiStatisticsPeriodDto todayRow = new ApiStatisticsPeriodDto();
|
||||||
|
todayRow.setPeriod(today.format(DAY_FMT));
|
||||||
|
todayRow.setTotalCount(todaySummary.getTotalCount());
|
||||||
|
todayRow.setSuccessCount(todaySummary.getSuccessCount());
|
||||||
|
todayRow.setTimeoutCount(todaySummary.getTimeoutCount());
|
||||||
|
todayRow.setErrorCount(todaySummary.getErrorCount());
|
||||||
|
todayRow.setHasData(true);
|
||||||
|
byDate.put(todayRow.getPeriod(), todayRow);
|
||||||
|
}
|
||||||
|
log.debug("[통계] 오늘분 HOUR 합산 - total={}", todaySummary.getTotalCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RangeStats(summary, details, fillDailyPeriods(byDate, start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, ApiStatisticsPeriodDto> rowsToMap(List<Object[]> rows) {
|
||||||
|
Map<String, ApiStatisticsPeriodDto> byDate = new LinkedHashMap<>();
|
||||||
|
if (rows != null) {
|
||||||
|
for (Object[] r : rows) {
|
||||||
|
ApiStatisticsPeriodDto dto = toPeriodDto(r);
|
||||||
|
byDate.put(dto.getPeriod(), dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 기간표를 지정 날짜 범위의 '모든 일자'로 채운다(데이터 없는 날은 hasData=false → 표에 '-').
|
||||||
|
* 각 행 라벨은 "yyyy-MM-dd (요일)".
|
||||||
|
*/
|
||||||
|
private static List<ApiStatisticsPeriodDto> buildDailyPeriods(List<Object[]> rows, LocalDate start, LocalDate end) {
|
||||||
|
return fillDailyPeriods(rowsToMap(rows), start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ApiStatisticsPeriodDto> fillDailyPeriods(Map<String, ApiStatisticsPeriodDto> byDate,
|
||||||
|
LocalDate start, LocalDate end) {
|
||||||
|
if (start == null || end == null) {
|
||||||
|
return new ArrayList<>(byDate.values());
|
||||||
|
}
|
||||||
|
List<ApiStatisticsPeriodDto> result = new ArrayList<>();
|
||||||
|
for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {
|
||||||
|
String key = d.format(DAY_FMT);
|
||||||
|
String label = key + " (" + WEEKDAYS[d.getDayOfWeek().getValue() - 1] + ")";
|
||||||
|
ApiStatisticsPeriodDto dto = byDate.get(key);
|
||||||
|
if (dto == null) {
|
||||||
|
dto = new ApiStatisticsPeriodDto();
|
||||||
|
dto.setPeriod(label);
|
||||||
|
dto.setTotalCount(0L);
|
||||||
|
dto.setSuccessCount(0L);
|
||||||
|
dto.setTimeoutCount(0L);
|
||||||
|
dto.setErrorCount(0L);
|
||||||
|
} else {
|
||||||
|
dto.setPeriod(label);
|
||||||
|
}
|
||||||
|
result.add(dto);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> List<T> nvl(List<T> l) {
|
||||||
|
return l != null ? new ArrayList<>(l) : new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiStatisticsSummaryDto orEmpty(ApiStatisticsSummaryDto s) {
|
||||||
|
return s != null ? s : ApiStatisticsSummaryDto.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long nz(Long v) {
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 요약(DAY + 오늘 HOUR)을 합산. rate 는 이후 calculateRates 에서 재계산.
|
||||||
|
*/
|
||||||
|
private static ApiStatisticsSummaryDto mergeSummaries(ApiStatisticsSummaryDto a, ApiStatisticsSummaryDto b) {
|
||||||
|
ApiStatisticsSummaryDto m = new ApiStatisticsSummaryDto();
|
||||||
|
m.setTotalCount(nz(a.getTotalCount()) + nz(b.getTotalCount()));
|
||||||
|
m.setSuccessCount(nz(a.getSuccessCount()) + nz(b.getSuccessCount()));
|
||||||
|
m.setTimeoutCount(nz(a.getTimeoutCount()) + nz(b.getTimeoutCount()));
|
||||||
|
m.setErrorCount(nz(a.getErrorCount()) + nz(b.getErrorCount()));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API별 상세를 API_NAME 기준으로 합산(DAY + 오늘 HOUR). rate 는 이후 calculateRates 에서 재계산.
|
||||||
|
*/
|
||||||
|
private static List<ApiStatisticsDetailDto> mergeDetails(List<ApiStatisticsDetailDto> a,
|
||||||
|
List<ApiStatisticsDetailDto> b) {
|
||||||
|
Map<String, ApiStatisticsDetailDto> acc = new LinkedHashMap<>();
|
||||||
|
accumulate(acc, a);
|
||||||
|
accumulate(acc, b);
|
||||||
|
List<ApiStatisticsDetailDto> merged = new ArrayList<>(acc.values());
|
||||||
|
merged.sort(Comparator.comparing(ApiStatisticsDetailDto::getTotalCount, Comparator.reverseOrder()));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void accumulate(Map<String, ApiStatisticsDetailDto> acc, List<ApiStatisticsDetailDto> list) {
|
||||||
|
if (list == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (ApiStatisticsDetailDto d : list) {
|
||||||
|
ApiStatisticsDetailDto cur = acc.computeIfAbsent(d.getApiName(), k -> {
|
||||||
|
ApiStatisticsDetailDto n = new ApiStatisticsDetailDto();
|
||||||
|
n.setApiName(k);
|
||||||
|
n.setTotalCount(0L);
|
||||||
|
n.setSuccessCount(0L);
|
||||||
|
n.setTimeoutCount(0L);
|
||||||
|
n.setErrorCount(0L);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
cur.setTotalCount(nz(cur.getTotalCount()) + nz(d.getTotalCount()));
|
||||||
|
cur.setSuccessCount(nz(cur.getSuccessCount()) + nz(d.getSuccessCount()));
|
||||||
|
cur.setTimeoutCount(nz(cur.getTimeoutCount()) + nz(d.getTimeoutCount()));
|
||||||
|
cur.setErrorCount(nz(cur.getErrorCount()) + nz(d.getErrorCount()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** combineDayAndToday 반환 홀더. */
|
||||||
|
private static final class RangeStats {
|
||||||
|
final ApiStatisticsSummaryDto summary;
|
||||||
|
final List<ApiStatisticsDetailDto> details;
|
||||||
|
final List<ApiStatisticsPeriodDto> periods;
|
||||||
|
|
||||||
|
RangeStats(ApiStatisticsSummaryDto summary, List<ApiStatisticsDetailDto> details,
|
||||||
|
List<ApiStatisticsPeriodDto> periods) {
|
||||||
|
this.summary = summary;
|
||||||
|
this.details = details;
|
||||||
|
this.periods = periods;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API별 상세의 apiName(인터페이스 ID)을 TSEAIHE01.EAISVCDESC(표시명)로 치환.
|
||||||
|
* 매핑 없으면 인터페이스 ID 를 그대로 둔다.
|
||||||
|
*/
|
||||||
|
private void applyDisplayNames(List<ApiStatisticsDetailDto> details) {
|
||||||
|
if (details == null || details.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<String> ids = details.stream()
|
||||||
|
.map(ApiStatisticsDetailDto::getApiName)
|
||||||
|
.filter(n -> n != null && !n.trim().isEmpty())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, String> descMap = gwSvcInfoRepository.findBySvcNameIn(ids).stream()
|
||||||
|
.filter(s -> s.getSvcDesc() != null && !s.getSvcDesc().trim().isEmpty())
|
||||||
|
.collect(Collectors.toMap(GwSvcInfo::getSvcName, GwSvcInfo::getSvcDesc, (x, y) -> x));
|
||||||
|
details.forEach(d -> {
|
||||||
|
String desc = descMap.get(d.getApiName());
|
||||||
|
if (desc != null && !desc.trim().isEmpty()) {
|
||||||
|
d.setApiName(desc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log.debug("[통계] API 표시명 매핑 - 대상 {}건, 매칭 {}건", ids.size(), descMap.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 모드에서 선택 가능한 월 목록(YYYYMM, 최신순).
|
||||||
|
*/
|
||||||
|
public List<String> getAvailableMonths(String orgId) {
|
||||||
|
List<String> clientIds = orgClientIds(orgId);
|
||||||
|
List<String> months = clientIds.isEmpty()
|
||||||
|
? new ArrayList<>()
|
||||||
|
: new ArrayList<>(apiStatsMonthRepository.findAvailableMonths(clientIds));
|
||||||
|
|
||||||
|
// 데이터가 없더라도 현재 년-월은 항상 선택 가능하도록 포함 (최신순 유지)
|
||||||
|
String currentYm = YearMonth.now().format(YYYYMM_FMT);
|
||||||
|
if (!months.contains(currentYm)) {
|
||||||
|
months.add(0, currentYm);
|
||||||
|
}
|
||||||
|
log.debug("[통계] 선택가능 월 {}건 (현재월 {} 포함) - orgId={}", months.size(), currentYm, orgId);
|
||||||
|
return months;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최신 집계 시각 안내 문자열("yyyy-MM-dd HH시"). 데이터 없으면 null.
|
||||||
|
*/
|
||||||
|
public String getLatestStatTime(String orgId) {
|
||||||
|
List<String> clientIds = orgClientIds(orgId);
|
||||||
|
if (clientIds.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
LocalDateTime latest = apiStatsHourRepository.findLatestStatTime(clientIds);
|
||||||
|
String result = latest != null ? latest.format(LATEST_FMT) : null;
|
||||||
|
log.debug("[통계] 최신 집계 시각 - {} (orgId={})", result, orgId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시간별 집계 잡의 크론에서 분(minute)을 추출한다. 파싱 불가 시 null → 문구는 "매시 집계"로 폴백.
|
||||||
|
* Quartz 크론 필드: [sec] [min] [hour] ... → 인덱스 1이 분.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Integer getAggregationMinute() {
|
||||||
|
try {
|
||||||
|
// 잡명은 PTL_PROPERTY(Portal/stats.hourly.aggregation.job.name)로 지정 가능(없으면 기본값 생성)
|
||||||
|
String jobName = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, JOB_NAME_PROP, DEFAULT_HOURLY_JOB_NAME,
|
||||||
|
"통계 시간별 집계 스케줄러 잡명(집계 주기 안내 문구용)");
|
||||||
|
Integer minute = jobInfoRepository.findByJobname(jobName)
|
||||||
|
.map(JobInfoCron::minuteOf)
|
||||||
|
.orElse(null);
|
||||||
|
log.debug("[통계] 집계 크론 분(N) = {} (job={})", minute, jobName);
|
||||||
|
return minute;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("집계 크론 조회 실패 - 안내 문구 분 표기 생략", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> orgClientIds(String orgId) {
|
||||||
|
return gwAuthClientRepository.findClientIdsByOrgId(orgId).stream()
|
||||||
|
.filter(id -> id != null && !id.trim().isEmpty())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 크론에서 분 필드를 추출하는 헬퍼.
|
||||||
|
*/
|
||||||
|
private static final class JobInfoCron {
|
||||||
|
static Integer minuteOf(com.eactive.apim.portal.apps.statistics.repository.entity.JobInfo job) {
|
||||||
|
String cron = job.getCronexp();
|
||||||
|
if (cron == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] fields = cron.trim().split("\\s+");
|
||||||
|
if (fields.length >= 2 && fields[1].matches("^\\d{1,2}$")) {
|
||||||
|
return Integer.parseInt(fields[1]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV 다운로드.
|
||||||
|
*
|
||||||
|
* ※ UI에서 다운로드 버튼은 제거되었으나(요청사항), 백엔드 기능은 유지한다.
|
||||||
|
* 외부/직접 호출로 재사용될 수 있어 엔드포인트(/statistics/api/download)와 함께 남겨둔다.
|
||||||
*/
|
*/
|
||||||
public void downloadCsv(String orgId, ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
|
public void downloadCsv(String orgId, ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
|
||||||
|
log.debug("[통계] CSV 다운로드 - orgId={}, mode={}", orgId, searchDto.getMode());
|
||||||
ApiStatisticsResultDto result = getStatistics(orgId, searchDto);
|
ApiStatisticsResultDto result = getStatistics(orgId, searchDto);
|
||||||
|
|
||||||
String fileName = String.format("API_Statistics_%s_%s.csv",
|
String rangeLabel = searchDto.isMonthly()
|
||||||
searchDto.getStartDate().toString(),
|
? searchDto.getMonth()
|
||||||
searchDto.getEndDate().toString());
|
: searchDto.getStartDate() + " ~ " + searchDto.getEndDate();
|
||||||
|
|
||||||
|
String fileName = String.format("API_Statistics_%s.csv",
|
||||||
|
rangeLabel.replace(" ", "").replace("~", "_"));
|
||||||
|
|
||||||
response.setContentType("text/csv; charset=UTF-8");
|
response.setContentType("text/csv; charset=UTF-8");
|
||||||
response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) + "\"");
|
response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) + "\"");
|
||||||
@@ -114,53 +473,52 @@ public class ApiStatisticsService {
|
|||||||
response.getOutputStream().write(0xBF);
|
response.getOutputStream().write(0xBF);
|
||||||
|
|
||||||
try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), true)) {
|
try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), true)) {
|
||||||
// 조회 조건
|
writer.println("조회 기간," + rangeLabel);
|
||||||
writer.println("조회 기간," + searchDto.getStartDate() + " ~ " + searchDto.getEndDate());
|
|
||||||
|
|
||||||
String appName = "전체 앱";
|
String appName = "전체 앱";
|
||||||
if (searchDto.hasSelectedApp()) {
|
if (searchDto.hasSelectedApp()) {
|
||||||
appName = credentialRepository.findById(searchDto.getClientId())
|
appName = gwAuthClientRepository.findById(searchDto.getClientId())
|
||||||
.map(Credential::getClientname)
|
.map(GwAuthClient::getClientName)
|
||||||
.orElse("선택된 앱");
|
.orElse("선택된 앱");
|
||||||
}
|
}
|
||||||
writer.println("앱 구분," + appName);
|
writer.println("앱 구분," + appName);
|
||||||
writer.println();
|
writer.println();
|
||||||
|
|
||||||
// 전체 통계
|
|
||||||
ApiStatisticsSummaryDto summary = result.getSummary();
|
ApiStatisticsSummaryDto summary = result.getSummary();
|
||||||
writer.println("[전체 통계]");
|
writer.println("[전체 통계]");
|
||||||
writer.println("총 호출 건," + summary.getTotalAttempted());
|
writer.println("총 호출 건," + summary.getTotalCount());
|
||||||
writer.println("성공 건," + summary.getTotalCompleted() + "," + summary.getSuccessRate() + "%");
|
writer.println("성공 건," + summary.getSuccessCount() + "," + summary.getSuccessRate() + "%");
|
||||||
writer.println("실패 건," + summary.getTotalFailed() + "," + summary.getFailureRate() + "%");
|
writer.println("타임아웃 건," + summary.getTimeoutCount() + "," + summary.getTimeoutRate() + "%");
|
||||||
|
writer.println("실패 건," + summary.getErrorCount() + "," + summary.getErrorRate() + "%");
|
||||||
writer.println();
|
writer.println();
|
||||||
|
|
||||||
// API별 통계 헤더
|
|
||||||
writer.println("[API별 통계]");
|
writer.println("[API별 통계]");
|
||||||
writer.println("API명,호출 건,성공 건,성공율,실패 건,실패율");
|
writer.println("API명,호출 건,성공 건,타임아웃 건,실패 건");
|
||||||
|
|
||||||
// API별 통계 데이터
|
|
||||||
for (ApiStatisticsDetailDto detail : result.getDetails()) {
|
for (ApiStatisticsDetailDto detail : result.getDetails()) {
|
||||||
writer.println(String.format("%s,%d,%d,%s%%,%d,%s%%",
|
writer.println(String.format("%s,%d,%d,%d,%d",
|
||||||
escapeCsv(detail.getApiName()),
|
escapeCsv(detail.getApiName()),
|
||||||
detail.getAttemptedCount(),
|
detail.getTotalCount(),
|
||||||
detail.getCompletedCount(),
|
detail.getSuccessCount(),
|
||||||
detail.getSuccessRate(),
|
detail.getTimeoutCount(),
|
||||||
detail.getFailedCount(),
|
detail.getErrorCount()));
|
||||||
detail.getFailureRate()));
|
|
||||||
}
|
}
|
||||||
|
writer.println();
|
||||||
|
|
||||||
// 전체 합계 행
|
writer.println("[기간별 통계]");
|
||||||
writer.println(String.format("전체,%d,%d,%s%%,%d,%s%%",
|
writer.println("기간,호출 건,성공 건,타임아웃 건,실패 건");
|
||||||
summary.getTotalAttempted(),
|
for (ApiStatisticsPeriodDto period : result.getPeriods()) {
|
||||||
summary.getTotalCompleted(),
|
writer.println(String.format("%s,%d,%d,%d,%d",
|
||||||
summary.getSuccessRate(),
|
escapeCsv(period.getPeriod()),
|
||||||
summary.getTotalFailed(),
|
period.getTotalCount(),
|
||||||
summary.getFailureRate()));
|
period.getSuccessCount(),
|
||||||
|
period.getTimeoutCount(),
|
||||||
|
period.getErrorCount()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV 값 이스케이프 (쉼표, 따옴표 처리)
|
* CSV 값 이스케이프 (쉼표, 따옴표 처리).
|
||||||
*/
|
*/
|
||||||
private String escapeCsv(String value) {
|
private String escapeCsv(String value) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
package com.eactive.apim.portal.apps.user.controller;
|
package com.eactive.apim.portal.apps.user.controller;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||||
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
|
import com.eactive.apim.portal.apps.user.dto.*;
|
||||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
|
||||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
|
||||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
|
||||||
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
|
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
|
||||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import javax.servlet.http.Cookie;
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
|
||||||
import javax.servlet.http.HttpSession;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -33,14 +23,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
|||||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.ResponseBody;
|
|
||||||
import org.springframework.web.servlet.ModelAndView;
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
import javax.servlet.http.Cookie;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@Secured("ROLE_ACCOUNT")
|
@Secured("ROLE_ACCOUNT")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -53,6 +48,7 @@ public class AccountController {
|
|||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementsFacade agreementsFacade;
|
||||||
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
||||||
private final UserInvitationRepository userInvitationRepository;
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
|
private final UserSessionService userSessionService;
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/confirm_password")
|
@PostMapping("/confirm_password")
|
||||||
@@ -105,6 +101,12 @@ public class AccountController {
|
|||||||
session.removeAttribute("success");
|
session.removeAttribute("success");
|
||||||
session.removeAttribute("redirectUrl");
|
session.removeAttribute("redirectUrl");
|
||||||
|
|
||||||
|
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
|
||||||
|
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
|
||||||
|
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
|
||||||
|
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
|
||||||
|
userSessionService.removeSession(session.getId());
|
||||||
|
|
||||||
new SecurityContextLogoutHandler().logout(request, response,
|
new SecurityContextLogoutHandler().logout(request, response,
|
||||||
SecurityContextHolder.getContext().getAuthentication());
|
SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
|
||||||
@@ -142,8 +144,8 @@ public class AccountController {
|
|||||||
// ROLE_USER인 경우 초대 여부 확인
|
// ROLE_USER인 경우 초대 여부 확인
|
||||||
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
||||||
java.util.Optional<UserInvitation> pendingInvitation =
|
java.util.Optional<UserInvitation> pendingInvitation =
|
||||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||||
user.getEmailAddr(), InvitationStatus.PENDING);
|
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
|
||||||
|
|
||||||
if (pendingInvitation.isPresent()) {
|
if (pendingInvitation.isPresent()) {
|
||||||
mav.addObject("hasPendingInvitation", true);
|
mav.addObject("hasPendingInvitation", true);
|
||||||
@@ -262,7 +264,7 @@ public class AccountController {
|
|||||||
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
return "apps/mypage/orgTransfer";
|
return "apps/mypage/orgTransfer";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.controller;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||||
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
|
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
|
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
|
||||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -22,18 +23,22 @@ public class AuthController {
|
|||||||
private final AuthFacade authFacade;
|
private final AuthFacade authFacade;
|
||||||
private final CellPhoneValidator cellPhoneValidator;
|
private final CellPhoneValidator cellPhoneValidator;
|
||||||
private final EmailValidator emailValidator;
|
private final EmailValidator emailValidator;
|
||||||
|
private final PortalUserService portalUserService;
|
||||||
|
|
||||||
|
|
||||||
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) {
|
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator,
|
||||||
|
PortalUserService portalUserService) {
|
||||||
this.authFacade = authFacade;
|
this.authFacade = authFacade;
|
||||||
this.cellPhoneValidator = cellPhoneValidator;
|
this.cellPhoneValidator = cellPhoneValidator;
|
||||||
this.emailValidator = emailValidator;
|
this.emailValidator = emailValidator;
|
||||||
|
this.portalUserService = portalUserService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/request_auth_number")
|
@PostMapping("/request_auth_number")
|
||||||
public ValidationResponse requestAuthNumber(
|
public ValidationResponse requestAuthNumber(
|
||||||
@RequestParam(required = false) String mobileNumber,
|
@RequestParam(required = false) String mobileNumber,
|
||||||
|
@RequestParam(required = false) String purpose,
|
||||||
HttpSession session) {
|
HttpSession session) {
|
||||||
|
|
||||||
ValidationResponse response = new ValidationResponse();
|
ValidationResponse response = new ValidationResponse();
|
||||||
@@ -44,6 +49,16 @@ public class AuthController {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 회원가입 흐름에서는 인증번호 발송 전에 휴대폰 번호 중복을 미리 검증한다.
|
||||||
|
// (중복 번호를 인증까지 마친 뒤 가입 단계에서야 거절되는 것을 방지)
|
||||||
|
// mobileNumber 는 저장 포맷과 동일한 하이픈 포함 형태이므로 sanitize 전 값으로 조회한다.
|
||||||
|
if ("signup".equals(purpose) && portalUserService.isMobileDuplicateCheckEnabled()
|
||||||
|
&& portalUserService.existsByMobileNumber(mobileNumber)) {
|
||||||
|
response.setValid(false);
|
||||||
|
response.setMessage("이미 가입된 휴대폰 번호입니다.");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", ""));
|
String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", ""));
|
||||||
|
|
||||||
// 이전 세션 데이터 정리
|
// 이전 세션 데이터 정리
|
||||||
|
|||||||
+5
-2
@@ -29,7 +29,8 @@ public class OrgRegisterController {
|
|||||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||||
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||||
|
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||||
return MAIN_ORG_REGISTER;
|
return MAIN_ORG_REGISTER;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ public class OrgRegisterController {
|
|||||||
|
|
||||||
if (response.isValid()) {
|
if (response.isValid()) {
|
||||||
model.addAttribute("message", response.getMessage());
|
model.addAttribute("message", response.getMessage());
|
||||||
|
model.addAttribute("registrationType", "corporate");
|
||||||
return MAIN_REGISTER_RESULT;
|
return MAIN_REGISTER_RESULT;
|
||||||
} else {
|
} else {
|
||||||
setModelForErrorOrg(model, orgDTO, response.getMessage());
|
setModelForErrorOrg(model, orgDTO, response.getMessage());
|
||||||
@@ -91,6 +93,7 @@ public class OrgRegisterController {
|
|||||||
model.addAttribute("portalOrg", orgDTO);
|
model.addAttribute("portalOrg", orgDTO);
|
||||||
model.addAttribute("error", errorMessage);
|
model.addAttribute("error", errorMessage);
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||||
|
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-15
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
|||||||
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
|
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
|
||||||
import com.eactive.apim.portal.common.dto.ResponseDTO;
|
import com.eactive.apim.portal.common.dto.ResponseDTO;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -18,12 +19,16 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class UserManRestController {
|
public class UserManRestController {
|
||||||
|
|
||||||
private final UserManFacade userManFacade;
|
private final UserManFacade userManFacade;
|
||||||
|
private final CellPhoneValidator cellPhoneValidator;
|
||||||
|
|
||||||
// 1. Cancel invitation
|
// 1. Cancel invitation
|
||||||
@PostMapping("/cancel-invitation")
|
@PostMapping("/cancel-invitation")
|
||||||
public ResponseDTO cancelInvitation(@RequestBody PortalUserDTO user) {
|
public ResponseDTO cancelInvitation(@RequestBody PortalUserDTO user) {
|
||||||
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
boolean notifyConsent = user.isNotifyConsent();
|
||||||
return new ResponseDTO(200, "SUCCESS", "초대가 취소되었습니다.");
|
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId(), notifyConsent);
|
||||||
|
return new ResponseDTO(200, "SUCCESS", notifyConsent
|
||||||
|
? "초대가 취소되었습니다.<br>취소 알림이 발송되었습니다."
|
||||||
|
: "초대가 취소되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Inactivate user
|
// 2. Inactivate user
|
||||||
@@ -40,25 +45,25 @@ public class UserManRestController {
|
|||||||
return new ResponseDTO(200, "SUCCESS", "사용자가 활성화되었습니다.");
|
return new ResponseDTO(200, "SUCCESS", "사용자가 활성화되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
//5. register new user
|
//5. register new user (휴대폰 번호 기반 초대)
|
||||||
@PostMapping("/invite")
|
@PostMapping("/invite")
|
||||||
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
|
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
|
||||||
String email = newUser.getEmailAddr();
|
String mobile = newUser.getMobileNumber();
|
||||||
|
|
||||||
// 이메일 형식 검증
|
// 휴대폰 번호 검증
|
||||||
if (email == null || email.trim().isEmpty()) {
|
if (mobile == null || mobile.trim().isEmpty()) {
|
||||||
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
|
return new ResponseDTO(400, "ERROR", "휴대폰 번호를 입력해주세요.");
|
||||||
|
}
|
||||||
|
if (!cellPhoneValidator.isValid(mobile, null)) {
|
||||||
|
return new ResponseDTO(400, "ERROR", "올바른 휴대폰 번호 형식을 입력해주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이메일 정규식 검증
|
boolean notifyConsent = newUser.isNotifyConsent();
|
||||||
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
|
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), mobile, notifyConsent);
|
||||||
if (!email.matches(emailPattern)) {
|
|
||||||
return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요.");
|
|
||||||
}
|
|
||||||
|
|
||||||
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
|
return new ResponseDTO(200, "SUCCESS", notifyConsent
|
||||||
|
? "초대 메시지가 발송되었습니다."
|
||||||
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
|
: "초대가 등록되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/change-role")
|
@PostMapping("/change-role")
|
||||||
@@ -69,6 +74,20 @@ public class UserManRestController {
|
|||||||
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
|
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 관리자 -> 이용자 변경 (본인 제외)
|
||||||
|
@PostMapping("/revoke-manager")
|
||||||
|
public ResponseDTO revokeManager(@RequestBody PortalUserDTO user) {
|
||||||
|
userManFacade.revokeManager(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||||
|
return new ResponseDTO(200, "SUCCESS", "이용자로 변경되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 소속 제외 -> 개인이용자로 전환
|
||||||
|
@PostMapping("/remove-from-org")
|
||||||
|
public ResponseDTO removeFromOrg(@RequestBody PortalUserDTO user) {
|
||||||
|
userManFacade.removeFromOrg(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||||
|
return new ResponseDTO(200, "SUCCESS", "소속에서 제외되어 개인이용자로 변경되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Resend invitation
|
// 6. Resend invitation
|
||||||
@PostMapping("/resend-invitation")
|
@PostMapping("/resend-invitation")
|
||||||
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
|
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
|
||||||
|
|||||||
+107
-44
@@ -4,11 +4,14 @@ import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
|||||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||||
|
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
|
||||||
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
|
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
|
||||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
import com.eactive.apim.portal.config.PortalProperties;
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||||
@@ -16,11 +19,15 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
|||||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
|
||||||
import java.security.InvalidKeyException;
|
import java.security.InvalidKeyException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import javax.crypto.BadPaddingException;
|
import javax.crypto.BadPaddingException;
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
import javax.crypto.IllegalBlockSizeException;
|
||||||
import javax.crypto.NoSuchPaddingException;
|
import javax.crypto.NoSuchPaddingException;
|
||||||
@@ -50,6 +57,7 @@ public class UserRegisterController {
|
|||||||
|
|
||||||
private final PortalUserService portalUserService;
|
private final PortalUserService portalUserService;
|
||||||
private final UserRegisterFacade userRegisterFacade;
|
private final UserRegisterFacade userRegisterFacade;
|
||||||
|
private final AuthFacade authFacade;
|
||||||
private final PortalUserRepository portalUserRepository;
|
private final PortalUserRepository portalUserRepository;
|
||||||
private final PortalOrgRepository portalOrgRepository;
|
private final PortalOrgRepository portalOrgRepository;
|
||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementsFacade agreementsFacade;
|
||||||
@@ -57,6 +65,7 @@ public class UserRegisterController {
|
|||||||
private final UserInvitationRepository userInvitationRepository;
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
private final EncryptionUtil encryptionUtil;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
|
private final PortalUserAuthService portalUserAuthService;
|
||||||
|
|
||||||
@GetMapping("/signup")
|
@GetMapping("/signup")
|
||||||
public String showSignupSelection() {
|
public String showSignupSelection() {
|
||||||
@@ -67,8 +76,6 @@ public class UserRegisterController {
|
|||||||
public String getUserAgreement(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, Model model) {
|
public String getUserAgreement(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, Model model) {
|
||||||
boolean isInvited = false;
|
boolean isInvited = false;
|
||||||
String orgName = null;
|
String orgName = null;
|
||||||
String email = null;
|
|
||||||
String[] parts = null;
|
|
||||||
|
|
||||||
if (invitationToken != null) {
|
if (invitationToken != null) {
|
||||||
// 8글자 토큰 사용
|
// 8글자 토큰 사용
|
||||||
@@ -80,29 +87,23 @@ public class UserRegisterController {
|
|||||||
String orgId = invitation.get().getOrgId();
|
String orgId = invitation.get().getOrgId();
|
||||||
Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
|
Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
|
||||||
orgName = org.map(PortalOrg::getOrgName).orElse(null);
|
orgName = org.map(PortalOrg::getOrgName).orElse(null);
|
||||||
email = invitation.get().getInvitationEmail();
|
|
||||||
parts = email.split("@");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
model.addAttribute("registrationType", isInvited ? "corporate" : "personal");
|
model.addAttribute("registrationType", isInvited ? "corporate" : "personal");
|
||||||
model.addAttribute("isInvited", isInvited);
|
model.addAttribute("isInvited", isInvited);
|
||||||
model.addAttribute("orgName", orgName);
|
model.addAttribute("orgName", orgName);
|
||||||
model.addAttribute("email", email);
|
// 휴대폰 번호 기반 초대 전환: 이메일/로그인ID 는 가입자가 직접 입력한다
|
||||||
model.addAttribute("loginId", email);
|
model.addAttribute("email", "");
|
||||||
|
model.addAttribute("loginId", "");
|
||||||
if (parts != null) {
|
|
||||||
model.addAttribute("emailId", parts[0]);
|
|
||||||
model.addAttribute("domain", parts[1]);
|
|
||||||
} else {
|
|
||||||
model.addAttribute("emailId", "");
|
model.addAttribute("emailId", "");
|
||||||
model.addAttribute("domain", "");
|
model.addAttribute("domain", "");
|
||||||
}
|
|
||||||
|
|
||||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||||
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
|
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||||
|
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||||
|
|
||||||
return MAIN_USER_REGISTER;
|
return MAIN_USER_REGISTER;
|
||||||
|
|
||||||
@@ -138,8 +139,21 @@ public class UserRegisterController {
|
|||||||
}
|
}
|
||||||
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
|
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
|
||||||
session.removeAttribute("invitationToken");
|
session.removeAttribute("invitationToken");
|
||||||
|
|
||||||
|
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
|
||||||
|
if (invitationToken == null) {
|
||||||
|
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
|
||||||
|
if (registered.isPresent()
|
||||||
|
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
|
||||||
|
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
|
||||||
|
return "redirect:/signup/verification-email";
|
||||||
|
}
|
||||||
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||||
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
|
return "redirect:/signup/complete";
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||||
|
return "redirect:/signup/complete/corporate";
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
setModelForError(session, model, agreement, portalUserRegistrationDTO,
|
setModelForError(session, model, agreement, portalUserRegistrationDTO,
|
||||||
"처리 중 오류가 발생했습니다: " + e.getMessage());
|
"처리 중 오류가 발생했습니다: " + e.getMessage());
|
||||||
@@ -169,6 +183,57 @@ public class UserRegisterController {
|
|||||||
return MAIN_EMAIL_COMPLETED;
|
return MAIN_EMAIL_COMPLETED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 직후 이메일 인증 페이지.
|
||||||
|
* 가입 단계에서 세션에 저장된 이메일이 있어야 진입 가능하다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/signup/verification-email")
|
||||||
|
public String showSignupVerificationEmail(HttpSession session, Model model) {
|
||||||
|
String email = (String) session.getAttribute("signupVerificationEmail");
|
||||||
|
if (email == null) {
|
||||||
|
return "redirect:/login";
|
||||||
|
}
|
||||||
|
model.addAttribute("email", email);
|
||||||
|
return "apps/register/signupVerificationEmail";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/signup/send-verification-code")
|
||||||
|
public ResponseEntity<ValidationResponse> sendSignupVerificationCode(HttpSession session) {
|
||||||
|
String email = (String) session.getAttribute("signupVerificationEmail");
|
||||||
|
if (email == null) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
|
||||||
|
}
|
||||||
|
ValidationResponse response = authFacade.requestAuth(email, "EMAIL");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 이메일 인증코드 검증. 성공 시 사용자 상태를 READY → ACTIVE 로 활성화한다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/signup/verify-email-code")
|
||||||
|
public ResponseEntity<ValidationResponse> verifySignupEmailCode(@RequestBody Map<String, String> request,
|
||||||
|
HttpSession session) {
|
||||||
|
String email = (String) session.getAttribute("signupVerificationEmail");
|
||||||
|
if (email == null) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
|
||||||
|
}
|
||||||
|
|
||||||
|
String code = request.get("code");
|
||||||
|
ValidationResponse response = authFacade.verifyAuthNumber(email, code);
|
||||||
|
|
||||||
|
if (response.isValid()) {
|
||||||
|
PortalUser user = portalUserService.findByEmailAddr(email);
|
||||||
|
portalUserService.activateUser(user.getId());
|
||||||
|
session.removeAttribute("signupVerificationEmail");
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/signup/decision")
|
@GetMapping("/signup/decision")
|
||||||
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
|
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
|
||||||
HttpSession session,
|
HttpSession session,
|
||||||
@@ -181,6 +246,11 @@ public class UserRegisterController {
|
|||||||
String decodedToken = decodeInvitationToken(invitationToken);
|
String decodedToken = decodeInvitationToken(invitationToken);
|
||||||
session.setAttribute("decisionToken", decodedToken);
|
session.setAttribute("decisionToken", decodedToken);
|
||||||
|
|
||||||
|
// 이미 로그인된 상태면 수락 화면으로 직행
|
||||||
|
if (SecurityUtil.isAuthenticated()) {
|
||||||
|
return "redirect:/signup/decision_process";
|
||||||
|
}
|
||||||
|
|
||||||
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
||||||
|
|
||||||
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
|
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
|
||||||
@@ -188,9 +258,9 @@ public class UserRegisterController {
|
|||||||
return "redirect:/";
|
return "redirect:/";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이메일로 사용자 정보 조회
|
// 휴대폰 번호로 사용자 정보 조회
|
||||||
String email = invitation.get().getInvitationEmail();
|
String mobile = invitation.get().getInvitationMobile();
|
||||||
Optional<PortalUser> portalUser = portalUserRepository.findPortalUserByEmailAddr(email);
|
Optional<PortalUser> portalUser = portalUserRepository.findAllByMobileNumber(mobile).stream().findFirst();
|
||||||
|
|
||||||
if (!portalUser.isPresent()) {
|
if (!portalUser.isPresent()) {
|
||||||
model.addAttribute("error", "사용자 정보를 찾을 수 없습니다.");
|
model.addAttribute("error", "사용자 정보를 찾을 수 없습니다.");
|
||||||
@@ -219,13 +289,14 @@ public class UserRegisterController {
|
|||||||
public String showDecisionProcessPage(HttpSession session, Model model) {
|
public String showDecisionProcessPage(HttpSession session, Model model) {
|
||||||
session.removeAttribute("decisionToken");
|
session.removeAttribute("decisionToken");
|
||||||
|
|
||||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
|
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
|
||||||
|
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
|
||||||
|
|
||||||
if (!invitation.isPresent()) {
|
if (!invitation.isPresent()) {
|
||||||
model.addAttribute("error", "유효하지 않은 초대입니다");
|
model.addAttribute("error", "유효하지 않은 초대입니다");
|
||||||
return "redirect:/";
|
return "redirect:/";
|
||||||
}
|
}
|
||||||
if (!SecurityUtil.getPortalAuthenticatedUser().getLoginId().equals(invitation.get().getInvitationEmail())) {
|
if (mobile == null || !mobile.equals(invitation.get().getInvitationMobile())) {
|
||||||
model.addAttribute("error", "유효하지 않은 초대입니다");
|
model.addAttribute("error", "유효하지 않은 초대입니다");
|
||||||
return "redirect:/";
|
return "redirect:/";
|
||||||
}
|
}
|
||||||
@@ -241,7 +312,9 @@ public class UserRegisterController {
|
|||||||
model.addAttribute("orgName", org.get().getOrgName());
|
model.addAttribute("orgName", org.get().getOrgName());
|
||||||
model.addAttribute("invitationCode", invitation.get().getToken());
|
model.addAttribute("invitationCode", invitation.get().getToken());
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||||
|
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||||
|
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
|
|
||||||
return "apps/register/userDecisionProcess";
|
return "apps/register/userDecisionProcess";
|
||||||
@@ -260,7 +333,7 @@ public class UserRegisterController {
|
|||||||
|
|
||||||
agreementValidator.validate(agreement, bindingResult);
|
agreementValidator.validate(agreement, bindingResult);
|
||||||
|
|
||||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
|
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
|
||||||
|
|
||||||
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
|
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
|
||||||
|
|
||||||
@@ -270,13 +343,20 @@ public class UserRegisterController {
|
|||||||
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
||||||
model.addAttribute("orgName", org.get().getOrgName());
|
model.addAttribute("orgName", org.get().getOrgName());
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||||
|
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||||
|
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
|
|
||||||
return "apps/register/userDecisionProcess";
|
return "apps/register/userDecisionProcess";
|
||||||
} else {
|
} else {
|
||||||
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
|
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
|
||||||
|
|
||||||
|
// 수락 성공 시 재로그인 없이 본인 세션 권한 갱신 (ROLE_USER → ROLE_CORP_USER)
|
||||||
|
if ("accept".equalsIgnoreCase(action) && response.isValid()) {
|
||||||
|
portalUserAuthService.reloadCurrentAuthentication();
|
||||||
|
}
|
||||||
|
|
||||||
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
|
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
|
||||||
session.removeAttribute("pendingInvitation");
|
session.removeAttribute("pendingInvitation");
|
||||||
session.removeAttribute("pendingInvitationToken");
|
session.removeAttribute("pendingInvitationToken");
|
||||||
@@ -294,24 +374,6 @@ public class UserRegisterController {
|
|||||||
return "redirect:/";
|
return "redirect:/";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/validate_email")
|
|
||||||
public String validateUserEmail(@RequestParam(name = "token", required = false) String token) {
|
|
||||||
String decodedToken = new String(Base64.decode(token));
|
|
||||||
try {
|
|
||||||
String decToken = encryptionUtil.decrypt(decodedToken);
|
|
||||||
String[] tokens = decToken.split(":");
|
|
||||||
boolean approvalRequired = portalUserService.activateUser(tokens[1]);
|
|
||||||
if (approvalRequired) {
|
|
||||||
return "apps/register/emailValidationResultCorpManager";
|
|
||||||
} else {
|
|
||||||
return "apps/register/emailValidationResultUser";
|
|
||||||
}
|
|
||||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException |
|
|
||||||
BadPaddingException e) {
|
|
||||||
throw new IllegalArgumentException("인증 정보 오류. 관리자 문의하세요.");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 에러 발생 시 모델 세팅을 위한 private 메서드
|
// 에러 발생 시 모델 세팅을 위한 private 메서드
|
||||||
private void setModelForError(HttpSession session, Model model, UserAgreementDTO agreement,
|
private void setModelForError(HttpSession session, Model model, UserAgreementDTO agreement,
|
||||||
@@ -325,7 +387,8 @@ public class UserRegisterController {
|
|||||||
model.addAttribute("agreement", agreement);
|
model.addAttribute("agreement", agreement);
|
||||||
|
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||||
|
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||||
model.addAttribute("msgType", "sms");
|
model.addAttribute("msgType", "sms");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,10 +500,10 @@ public class UserRegisterController {
|
|||||||
session.removeAttribute("invitationCodeAttempts");
|
session.removeAttribute("invitationCodeAttempts");
|
||||||
session.removeAttribute("invitationCodeLockoutUntil");
|
session.removeAttribute("invitationCodeLockoutUntil");
|
||||||
|
|
||||||
// 기존 사용자 여부 확인
|
// 기존 사용자 여부 확인 (휴대폰 번호 기반)
|
||||||
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
|
boolean existingUser = !portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile()).isEmpty();
|
||||||
|
|
||||||
if (existingUser.isPresent()) {
|
if (existingUser) {
|
||||||
// 기존 사용자: 초대 수락 페이지로 이동
|
// 기존 사용자: 초대 수락 페이지로 이동
|
||||||
return "redirect:/signup/decision?invitation=" + normalizedCode;
|
return "redirect:/signup/decision?invitation=" + normalizedCode;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ public class PortalUserDTO implements Serializable {
|
|||||||
|
|
||||||
private String emailAddr;
|
private String emailAddr;
|
||||||
|
|
||||||
|
// 초대 시 알림 수신 동의 여부 (false 면 초대 레코드만 생성, 메시지 미발송)
|
||||||
|
private boolean notifyConsent;
|
||||||
|
|
||||||
private PortalOrgDTO portalOrg;
|
private PortalOrgDTO portalOrg;
|
||||||
|
|
||||||
private UserStatus userStatus;
|
private UserStatus userStatus;
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
// 사용자 생성 및 기관 연결
|
// 사용자 생성 및 기관 연결
|
||||||
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
|
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
|
||||||
|
|
||||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
|
|
||||||
approvalService.createUserApproval(newUser);
|
approvalService.createUserApproval(newUser);
|
||||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||||
@@ -240,7 +240,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
|
|
||||||
portalUserRepository.save(existingUser);
|
portalUserRepository.save(existingUser);
|
||||||
agreementsFacade.deleteUserAgreements(existingUser.getId()); //기존 개인약관 동의 삭제
|
agreementsFacade.deleteUserAgreements(existingUser.getId()); //기존 개인약관 동의 삭제
|
||||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
|
|
||||||
approvalService.createUserApproval(existingUser);
|
approvalService.createUserApproval(existingUser);
|
||||||
|
|
||||||
@@ -274,7 +274,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
portalUserRepository.save(existingUser);
|
portalUserRepository.save(existingUser);
|
||||||
|
|
||||||
// 새로운 법인용 개인정보수집동의서 저장
|
// 새로운 법인용 개인정보수집동의서 저장
|
||||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
|
|
||||||
approvalService.createUserApproval(existingUser);
|
approvalService.createUserApproval(existingUser);
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user