Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67ec6d2103 | |||
| b024f8e86f | |||
| fd16300553 | |||
| 815c329e3c | |||
| 7fdd70dbc7 | |||
| 3dd9edc849 | |||
| 6cff96f40a | |||
| a97609d533 |
+13
-51
@@ -1,6 +1,10 @@
|
||||
pipeline {
|
||||
agent { label 'djb-vm' }
|
||||
|
||||
parameters {
|
||||
string(name: 'BRANCH', defaultValue: 'master', description: 'Branch to deploy')
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
@@ -19,13 +23,16 @@ pipeline {
|
||||
CATALINA_HOME = '/prod/eapim/apache-tomcat-9.0.116'
|
||||
CATALINA_PID = '/prod/eapim/devportal/temp/catalina.pid'
|
||||
DEPLOY_HTTP_PORT = '39130'
|
||||
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: "*/${params.BRANCH}"]],
|
||||
userRemoteConfigs: [[url: 'ssh://git@172.30.1.50:2222/djb-eapim/eapim-portal.git']]
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +43,6 @@ pipeline {
|
||||
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
|
||||
@@ -48,7 +54,6 @@ pipeline {
|
||||
|
||||
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
|
||||
@@ -71,34 +76,19 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
sh 'gradle clean 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
|
||||
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
|
||||
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
|
||||
'''
|
||||
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
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,32 +179,4 @@ pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
sh '''
|
||||
set +e
|
||||
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS","attachments":[{"color":"good","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"SUCCESS","short":true}]}]}' \
|
||||
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
|
||||
curl -sS --fail --max-time 5 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
--data "$payload" \
|
||||
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for SUCCESS"
|
||||
'''
|
||||
}
|
||||
failure {
|
||||
sh '''
|
||||
set +e
|
||||
payload=$(printf '{"text":"[%s #%s](%s) FAILURE","attachments":[{"color":"danger","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"FAILURE","short":true}]}]}' \
|
||||
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
|
||||
curl -sS --fail --max-time 5 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
--data "$payload" \
|
||||
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for FAILURE"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -91,13 +91,10 @@ pipeline {
|
||||
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
|
||||
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
|
||||
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
|
||||
'''
|
||||
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
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/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
|
||||
+5
-7
@@ -3,7 +3,6 @@ plugins {
|
||||
id 'war'
|
||||
id 'eclipse'
|
||||
id 'idea'
|
||||
id 'org.cyclonedx.bom' version '3.2.4'
|
||||
id 'org.springframework.boot' version '2.7.18'
|
||||
id 'io.spring.dependency-management' version '1.1.3'
|
||||
}
|
||||
@@ -151,11 +150,11 @@ compileJava {
|
||||
}
|
||||
|
||||
processResources {
|
||||
// exclude { details ->
|
||||
// details.file.name.startsWith('application-') &&
|
||||
// details.file.name.endsWith('.yml') &&
|
||||
// !(details.file.name in ['application-stage.yml', 'application-prod.yml'])
|
||||
// }
|
||||
exclude { details ->
|
||||
details.file.name.startsWith('application-') &&
|
||||
details.file.name.endsWith('.yml') &&
|
||||
!(details.file.name in ['application-stage.yml', 'application-prod.yml'])
|
||||
}
|
||||
}
|
||||
|
||||
test {
|
||||
@@ -171,7 +170,6 @@ test {
|
||||
|
||||
bootWar {
|
||||
archiveFileName = "eapim-portal-boot.war"
|
||||
mainClass = 'com.eactive.apim.portal.PortalApplication'
|
||||
}
|
||||
|
||||
war {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
./gradlew bootWar
|
||||
docker build -t eactive-portal .
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/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 $@
|
||||
Generated
-47
@@ -8,9 +8,6 @@
|
||||
"name": "eapim-portal",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"playwright": "^1.61.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass": "^1.69.5"
|
||||
}
|
||||
@@ -352,20 +349,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
@@ -420,36 +403,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
|
||||
+1
-4
@@ -13,8 +13,5 @@
|
||||
"sass": "^1.69.5"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"playwright": "^1.61.1"
|
||||
}
|
||||
"license": "ISC"
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
|
||||
-- 대상: 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;
|
||||
+9
-34
@@ -15,11 +15,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
@Controller
|
||||
@RequestMapping("/agreements")
|
||||
public class AgreementsController {
|
||||
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";
|
||||
public static final String TERMS_AGREEMENTS = "fragment/kjbank/terms_agreements";
|
||||
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
|
||||
@@ -32,49 +28,28 @@ public class AgreementsController {
|
||||
public String showTerms(@RequestParam(required = false) String tab,
|
||||
@RequestParam(required = false) String publishedOn,
|
||||
Model model) {
|
||||
String currentTab = tab != null ? tab : "terms";
|
||||
|
||||
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
|
||||
if ("privacy".equals(currentTab)) {
|
||||
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
|
||||
}
|
||||
|
||||
AgreementType type;
|
||||
switch (currentTab) {
|
||||
case "privacy-collect":
|
||||
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
|
||||
type = AgreementType.PRIVACY_COLLECT;
|
||||
break;
|
||||
case "notification":
|
||||
type = AgreementType.NOTIFICATION_CONSENT;
|
||||
break;
|
||||
case "terms":
|
||||
default:
|
||||
currentTab = "terms";
|
||||
type = AgreementType.TERMS_OF_USE;
|
||||
break;
|
||||
if ("privacy".equals(tab)) {
|
||||
type = AgreementType.PRIVACY_POLICY;
|
||||
} else {
|
||||
type = AgreementType.TERMS_OF_USE;
|
||||
}
|
||||
|
||||
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
|
||||
model.addAttribute("agreementsList", agreementsList);
|
||||
|
||||
// 해당 약관 종류가 아직 시드되지 않았을 수 있으므로 빈 리스트 방어
|
||||
AgreementsDTO selectedAgreement = null;
|
||||
if (agreementsList != null && !agreementsList.isEmpty()) {
|
||||
selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
|
||||
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
|
||||
}
|
||||
AgreementsDTO selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
|
||||
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
|
||||
|
||||
model.addAttribute("selectedAgreement", selectedAgreement);
|
||||
model.addAttribute("selectedDate", publishedOn);
|
||||
|
||||
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
|
||||
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
|
||||
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
|
||||
model.addAttribute("isPrivacyPolicy", type == AgreementType.PRIVACY_POLICY);
|
||||
model.addAttribute("agreementTitle", type.getDescription());
|
||||
model.addAttribute("agreementType", type.getCode());
|
||||
|
||||
model.addAttribute("currentTab", currentTab);
|
||||
model.addAttribute("currentTab", tab != null ? tab : "terms");
|
||||
|
||||
return TERMS_AGREEMENTS;
|
||||
}
|
||||
|
||||
+2
-7
@@ -71,9 +71,8 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
||||
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
|
||||
}
|
||||
|
||||
// 2. 개인정보수집동의서 처리 (개인정보수집동의서 = PRIVACY_COLLECT 신설항목 포함)
|
||||
if (privacyCollectType != null
|
||||
&& (privacyCollectType.isPrivacyCollect() || privacyCollectType == AgreementType.PRIVACY_COLLECT)) {
|
||||
// 2. 개인정보수집동의서 처리
|
||||
if (privacyCollectType != null && privacyCollectType.isPrivacyCollect()) {
|
||||
Optional<Agreements> privacyCollect = agreementsService
|
||||
.findLastesBeforeDate(
|
||||
privacyCollectType,
|
||||
@@ -200,10 +199,6 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
||||
defaultAgreement.setName("개인정보처리방침");
|
||||
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
break;
|
||||
case "PRIVACY_COLLECT":
|
||||
defaultAgreement.setName("개인정보수집동의서");
|
||||
defaultAgreement.setContents("현재 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
break;
|
||||
case "PRIVACY_COLLECT_IND":
|
||||
defaultAgreement.setName("개인용 개인정보수집동의서");
|
||||
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
|
||||
@@ -31,21 +31,4 @@ public class ApprovalDTO {
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
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,72 +3,31 @@ package com.eactive.apim.portal.apps.auth.service;
|
||||
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
|
||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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.util.Optional;
|
||||
|
||||
@Component
|
||||
public class AuthNumberStorage {
|
||||
|
||||
private static final String HASH_ALGORITHM = "HmacSHA256";
|
||||
|
||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||
|
||||
/**
|
||||
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
|
||||
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
|
||||
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
|
||||
*/
|
||||
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
||||
private String hashPepper;
|
||||
|
||||
@Autowired
|
||||
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
|
||||
this.twoFactorAuthRepository = twoFactorAuthRepository;
|
||||
}
|
||||
|
||||
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
|
||||
String lookupKey = hashRecipient(recipientKey);
|
||||
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
|
||||
twoFactorAuthRepository.deleteAllByRecipient(recipientKey);
|
||||
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
|
||||
auth.setRecipientKey(lookupKey);
|
||||
twoFactorAuthRepository.save(auth);
|
||||
}
|
||||
|
||||
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
|
||||
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
|
||||
return twoFactorAuthRepository.findByRecipient(recipientKey);
|
||||
}
|
||||
|
||||
public void deleteAuthNumber(String recipientKey) {
|
||||
twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
|
||||
twoFactorAuthRepository.deleteById(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
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,18 +8,11 @@ import org.hibernate.validator.constraints.Length;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
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;
|
||||
|
||||
@NotEmpty(message = "제목을 입력해주세요.")
|
||||
@@ -36,33 +29,9 @@ public class PortalNoticeDTO {
|
||||
|
||||
private boolean useYn;
|
||||
|
||||
private String fixYn;
|
||||
|
||||
private String noticeType;
|
||||
|
||||
private String inquirerName;
|
||||
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
+3
-45
@@ -1,12 +1,8 @@
|
||||
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.PortalNoticeSearch;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -16,10 +12,7 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service("portalNoticeFacade")
|
||||
@RequiredArgsConstructor
|
||||
@@ -27,13 +20,12 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
|
||||
private final PortalNoticeService portalNoticeService;
|
||||
private final PortalNoticeMapper portalNoticeMapper;
|
||||
private final DjbApistatusIncidentRepository incidentRepository;
|
||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||
|
||||
@Override
|
||||
public List<PortalNoticeDTO> getLatestNotices() {
|
||||
PortalNoticeSearch search = new PortalNoticeSearch();
|
||||
Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdDate"));
|
||||
// Pageable pageable = Pageable.ofSize(3);
|
||||
Page<PortalNoticeDTO> notices = getNotices(search, pageable);
|
||||
return notices.getContent();
|
||||
}
|
||||
@@ -44,47 +36,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
Specification<PortalNotice> spec = Specification.where(search.buildSpecification())
|
||||
.and((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("useYn"),"Y"));
|
||||
|
||||
// 상단고정(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);
|
||||
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, pageable);
|
||||
return noticesPage.map(portalNoticeMapper::map);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PortalNoticeDTO getNotice(String id) {
|
||||
PortalNotice portalNotice = portalNoticeService.getNotice(id);
|
||||
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);
|
||||
return portalNoticeMapper.map(portalNotice);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ public class PartnershipApplicationController {
|
||||
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
||||
|
||||
// 성공 메시지 추가
|
||||
redirectAttributes.addFlashAttribute("success", "피드백/개선요청 등록이 완료되었습니다.");
|
||||
redirectAttributes.addFlashAttribute("success", "사업제휴신청이 완료되었습니다.");
|
||||
|
||||
httpServletRequest.getSession().removeAttribute("previousPage");
|
||||
return "redirect:/partnership";
|
||||
|
||||
+1
-17
@@ -5,18 +5,14 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
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.service.FileService;
|
||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -25,15 +21,13 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
private final PartnershipApplicationService partnershipApplicationService;
|
||||
private final PartnershipApplicationMapper partnershipApplicationMapper;
|
||||
private final FileService fileService;
|
||||
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
|
||||
private final CommunityAdminNotifier portalAdminNotifier;
|
||||
|
||||
|
||||
@Override
|
||||
public void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException {
|
||||
|
||||
FileInfo file = null;
|
||||
if (partnershipApplicationDTO.getFiles() != null && !partnershipApplicationDTO.getFiles().isEmpty()) {
|
||||
if (!partnershipApplicationDTO.getFiles().isEmpty()) {
|
||||
try {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
|
||||
@@ -49,15 +43,5 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
partnershipApplication.setFileId(file.getFileId());
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-15
@@ -6,11 +6,7 @@ import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -33,12 +29,9 @@ public class InquiryController {
|
||||
public static final String APPS_COMMUNITY_MAIN_INQUIRY_FORM = "apps/community/mainInquiryForm";
|
||||
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
|
||||
private final InquiryFacade inquiryFacade;
|
||||
private final InquiryCommentFacade inquiryCommentFacade;
|
||||
|
||||
public InquiryController(InquiryFacade inquiryFacade,
|
||||
InquiryCommentFacade inquiryCommentFacade) {
|
||||
public InquiryController(InquiryFacade inquiryFacade) {
|
||||
this.inquiryFacade = inquiryFacade;
|
||||
this.inquiryCommentFacade = inquiryCommentFacade;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,13 +42,8 @@ public class InquiryController {
|
||||
Model model) {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
Page<InquiryDTO> inquiries = inquiryFacade.getInquiries(search, pageable, user);
|
||||
List<String> ids = inquiries.getContent().stream()
|
||||
.map(InquiryDTO::getId)
|
||||
.collect(Collectors.toList());
|
||||
Map<String, Long> commentCounts = inquiryCommentFacade.countActiveByInquiryIds(ids);
|
||||
model.addAttribute("inquiries", inquiries.getContent());
|
||||
model.addAttribute("page", inquiries);
|
||||
model.addAttribute("commentCounts", commentCounts);
|
||||
return "apps/community/mainInquiryList";
|
||||
}
|
||||
|
||||
@@ -69,9 +57,8 @@ public class InquiryController {
|
||||
return REDIRECT_INQUIRY;
|
||||
}
|
||||
|
||||
InquiryDTO inquiry = inquiryFacade.getAccessibleInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
|
||||
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
|
||||
model.addAttribute("inquiry", inquiry);
|
||||
model.addAttribute("commentWritable", !"CLOSED".equals(inquiry.getInquiryStatus()));
|
||||
return "apps/community/mainInquiryDetail";
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.validator.constraints.NotEmpty;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.validation.constraints.Pattern;
|
||||
import javax.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@@ -25,7 +28,7 @@ public class InquiryDTO {
|
||||
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
|
||||
private String inquiryDetail;
|
||||
|
||||
@Pattern(regexp = "^(PENDING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
||||
@Pattern(regexp = "^(PENDING|IN_PROGRESS|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
||||
private String inquiryStatus;
|
||||
|
||||
private String inquirerName;
|
||||
@@ -47,6 +50,8 @@ public class InquiryDTO {
|
||||
|
||||
private LocalDateTime lastModifiedDate;
|
||||
|
||||
private List<MultipartFile> files;
|
||||
|
||||
private String attachFile;
|
||||
|
||||
private String maskedInquirerName;
|
||||
|
||||
@@ -15,8 +15,6 @@ public interface InquiryFacade {
|
||||
|
||||
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
|
||||
|
||||
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
|
||||
|
||||
void createInquiry(InquiryDTO inquiryDTO) throws IOException;
|
||||
|
||||
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException;
|
||||
|
||||
+43
-17
@@ -6,10 +6,12 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -18,15 +20,14 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InquiryFacadeImpl implements InquiryFacade {
|
||||
|
||||
private final FileService fileService;
|
||||
private final InquiryService inquiryService;
|
||||
private final InquiryMapper inquiryMapper;
|
||||
private final CommunityAdminNotifier inquiryAdminNotifier;
|
||||
|
||||
@Override
|
||||
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
|
||||
@@ -78,30 +79,55 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
return inquiryMapper.map(inquiry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
|
||||
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
|
||||
return inquiryMapper.map(inquiry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
FileInfo file = null;
|
||||
if (!inquiryDTO.getFiles().isEmpty()) {
|
||||
try {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
|
||||
|
||||
FileTypeContext.setFileType("qna");
|
||||
file = fileService.createFile(inquiryDTO.getFiles());
|
||||
} finally {
|
||||
FileService.clearInternalUserContext();
|
||||
}
|
||||
}
|
||||
|
||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||
inquiry.setInquirer(current);
|
||||
if (file != null) {
|
||||
inquiry.setAttachFile(file.getFileId());
|
||||
}
|
||||
inquiry.setInquirer(SecurityUtil.getPortalAuthenticatedUser());
|
||||
inquiryService.createInquiry(inquiry);
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
HashMap<String, Object> params = new HashMap<>();
|
||||
params.put("inquiryId", inquiry.getId());
|
||||
params.put("inquirySubject", inquiry.getInquirySubject());
|
||||
params.put("writerName", current.getUserName());
|
||||
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
|
||||
params.put("subject", inquiry.getInquirySubject());
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException {
|
||||
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
|
||||
inquiryDTO.setAttachFile(existingInquiry.getAttachFile());
|
||||
|
||||
if (!inquiryDTO.getFiles().isEmpty() && !inquiryDTO.getFiles().get(0).isEmpty()) {
|
||||
try {
|
||||
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
|
||||
|
||||
FileTypeContext.setFileType("qna");
|
||||
FileInfo file = fileService.createFile(inquiryDTO.getFiles());
|
||||
if(file != null) {
|
||||
inquiryDTO.setAttachFile(file.getFileId());
|
||||
}
|
||||
} finally {
|
||||
FileService.clearInternalUserContext();
|
||||
}
|
||||
} else if (existingInquiry.getAttachFile() != null && inquiryDTO.getAttachFile() == null) {
|
||||
fileService.deleteFile(inquiryDTO.getAttachFile());
|
||||
inquiryDTO.setAttachFile(null);
|
||||
}
|
||||
|
||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||
inquiry.setInquirer(existingInquiry.getInquirer());
|
||||
|
||||
@@ -4,9 +4,6 @@ package com.eactive.apim.portal.apps.community.qna.service;
|
||||
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
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.qna.entity.Inquiry;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -93,36 +90,5 @@ public class InquiryService {
|
||||
return inquiryRepository.findByInquirerAndId(user, id).orElseThrow(() -> new NotFoundException("내 질문을 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
/**
|
||||
* 본인 또는 같은 법인 소속 이용자가 작성한 Q&A를 조회한다.
|
||||
* - ROLE_USER: 본인 글만 접근 가능
|
||||
* - ROLE_CORP_*: 같은 법인 소속 이용자가 작성한 글까지 접근 가능
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Inquiry getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
|
||||
Inquiry inquiry = getInquiry(id);
|
||||
if (!isAccessibleByUser(user, inquiry)) {
|
||||
throw new NotFoundException("문의글을 찾을 수 없습니다. id=" + id);
|
||||
}
|
||||
return inquiry;
|
||||
}
|
||||
|
||||
private boolean isAccessibleByUser(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;
|
||||
}
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
return false;
|
||||
}
|
||||
PortalOrg userOrg = user.getPortalOrg();
|
||||
PortalOrg inquirerOrg = inquirer.getPortalOrg();
|
||||
return userOrg != null && inquirerOrg != null
|
||||
&& userOrg.getId() != null
|
||||
&& userOrg.getId().equals(inquirerOrg.getId());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+19
@@ -152,6 +152,25 @@ 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")
|
||||
public String showDormantAccountPage(HttpSession session,Model model) {
|
||||
|
||||
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
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 "***";
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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
@@ -1,124 +0,0 @@
|
||||
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
@@ -1,33 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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";
|
||||
|
||||
/** 논리적 만료(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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 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;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.user.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.dto.*;
|
||||
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
|
||||
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.UserFacade;
|
||||
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.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
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 org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -23,19 +33,14 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.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
|
||||
@Secured("ROLE_ACCOUNT")
|
||||
@RequiredArgsConstructor
|
||||
@@ -48,7 +53,6 @@ public class AccountController {
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
|
||||
@PostMapping("/confirm_password")
|
||||
@@ -101,12 +105,6 @@ public class AccountController {
|
||||
session.removeAttribute("success");
|
||||
session.removeAttribute("redirectUrl");
|
||||
|
||||
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
|
||||
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
|
||||
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
|
||||
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
|
||||
userSessionService.removeSession(session.getId());
|
||||
|
||||
new SecurityContextLogoutHandler().logout(request, response,
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
|
||||
@@ -144,8 +142,8 @@ public class AccountController {
|
||||
// ROLE_USER인 경우 초대 여부 확인
|
||||
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
java.util.Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
||||
user.getEmailAddr(), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
mav.addObject("hasPendingInvitation", true);
|
||||
@@ -264,7 +262,7 @@ public class AccountController {
|
||||
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
return "apps/mypage/orgTransfer";
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.controller;
|
||||
|
||||
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.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -23,22 +22,18 @@ public class AuthController {
|
||||
private final AuthFacade authFacade;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final EmailValidator emailValidator;
|
||||
private final PortalUserService portalUserService;
|
||||
|
||||
|
||||
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator,
|
||||
PortalUserService portalUserService) {
|
||||
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) {
|
||||
this.authFacade = authFacade;
|
||||
this.cellPhoneValidator = cellPhoneValidator;
|
||||
this.emailValidator = emailValidator;
|
||||
this.portalUserService = portalUserService;
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/request_auth_number")
|
||||
public ValidationResponse requestAuthNumber(
|
||||
@RequestParam(required = false) String mobileNumber,
|
||||
@RequestParam(required = false) String purpose,
|
||||
HttpSession session) {
|
||||
|
||||
ValidationResponse response = new ValidationResponse();
|
||||
@@ -49,16 +44,6 @@ public class AuthController {
|
||||
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("-", ""));
|
||||
|
||||
// 이전 세션 데이터 정리
|
||||
|
||||
+2
-5
@@ -29,8 +29,7 @@ public class OrgRegisterController {
|
||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
return MAIN_ORG_REGISTER;
|
||||
}
|
||||
|
||||
@@ -75,7 +74,6 @@ public class OrgRegisterController {
|
||||
|
||||
if (response.isValid()) {
|
||||
model.addAttribute("message", response.getMessage());
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
return MAIN_REGISTER_RESULT;
|
||||
} else {
|
||||
setModelForErrorOrg(model, orgDTO, response.getMessage());
|
||||
@@ -93,7 +91,6 @@ public class OrgRegisterController {
|
||||
model.addAttribute("portalOrg", orgDTO);
|
||||
model.addAttribute("error", errorMessage);
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
}
|
||||
}
|
||||
|
||||
+15
-34
@@ -4,7 +4,6 @@ import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
|
||||
import com.eactive.apim.portal.common.dto.ResponseDTO;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -19,16 +18,12 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class UserManRestController {
|
||||
|
||||
private final UserManFacade userManFacade;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
|
||||
// 1. Cancel invitation
|
||||
@PostMapping("/cancel-invitation")
|
||||
public ResponseDTO cancelInvitation(@RequestBody PortalUserDTO user) {
|
||||
boolean notifyConsent = user.isNotifyConsent();
|
||||
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId(), notifyConsent);
|
||||
return new ResponseDTO(200, "SUCCESS", notifyConsent
|
||||
? "초대가 취소되었습니다.<br>취소 알림이 발송되었습니다."
|
||||
: "초대가 취소되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
|
||||
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||
return new ResponseDTO(200, "SUCCESS", "초대가 취소되었습니다.");
|
||||
}
|
||||
|
||||
// 2. Inactivate user
|
||||
@@ -45,25 +40,25 @@ public class UserManRestController {
|
||||
return new ResponseDTO(200, "SUCCESS", "사용자가 활성화되었습니다.");
|
||||
}
|
||||
|
||||
//5. register new user (휴대폰 번호 기반 초대)
|
||||
//5. register new user
|
||||
@PostMapping("/invite")
|
||||
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
|
||||
String mobile = newUser.getMobileNumber();
|
||||
String email = newUser.getEmailAddr();
|
||||
|
||||
// 휴대폰 번호 검증
|
||||
if (mobile == null || mobile.trim().isEmpty()) {
|
||||
return new ResponseDTO(400, "ERROR", "휴대폰 번호를 입력해주세요.");
|
||||
}
|
||||
if (!cellPhoneValidator.isValid(mobile, null)) {
|
||||
return new ResponseDTO(400, "ERROR", "올바른 휴대폰 번호 형식을 입력해주세요.");
|
||||
// 이메일 형식 검증
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
|
||||
}
|
||||
|
||||
boolean notifyConsent = newUser.isNotifyConsent();
|
||||
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), mobile, notifyConsent);
|
||||
// 이메일 정규식 검증
|
||||
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
|
||||
if (!email.matches(emailPattern)) {
|
||||
return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요.");
|
||||
}
|
||||
|
||||
return new ResponseDTO(200, "SUCCESS", notifyConsent
|
||||
? "초대 메시지가 발송되었습니다."
|
||||
: "초대가 등록되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
|
||||
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
|
||||
|
||||
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
|
||||
}
|
||||
|
||||
@PostMapping("/change-role")
|
||||
@@ -74,20 +69,6 @@ public class UserManRestController {
|
||||
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
|
||||
@PostMapping("/resend-invitation")
|
||||
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
|
||||
|
||||
+46
-109
@@ -4,14 +4,11 @@ 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.UserAgreementDTO;
|
||||
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.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.validator.AgreementValidator;
|
||||
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.config.PortalProperties;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
@@ -19,15 +16,11 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
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.repository.PortalUserRepository;
|
||||
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
@@ -57,7 +50,6 @@ public class UserRegisterController {
|
||||
|
||||
private final PortalUserService portalUserService;
|
||||
private final UserRegisterFacade userRegisterFacade;
|
||||
private final AuthFacade authFacade;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
@@ -65,7 +57,6 @@ public class UserRegisterController {
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
@GetMapping("/signup")
|
||||
public String showSignupSelection() {
|
||||
@@ -76,6 +67,8 @@ public class UserRegisterController {
|
||||
public String getUserAgreement(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, Model model) {
|
||||
boolean isInvited = false;
|
||||
String orgName = null;
|
||||
String email = null;
|
||||
String[] parts = null;
|
||||
|
||||
if (invitationToken != null) {
|
||||
// 8글자 토큰 사용
|
||||
@@ -87,23 +80,29 @@ public class UserRegisterController {
|
||||
String orgId = invitation.get().getOrgId();
|
||||
Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
|
||||
orgName = org.map(PortalOrg::getOrgName).orElse(null);
|
||||
email = invitation.get().getInvitationEmail();
|
||||
parts = email.split("@");
|
||||
}
|
||||
}
|
||||
|
||||
model.addAttribute("registrationType", isInvited ? "corporate" : "personal");
|
||||
model.addAttribute("isInvited", isInvited);
|
||||
model.addAttribute("orgName", orgName);
|
||||
// 휴대폰 번호 기반 초대 전환: 이메일/로그인ID 는 가입자가 직접 입력한다
|
||||
model.addAttribute("email", "");
|
||||
model.addAttribute("loginId", "");
|
||||
model.addAttribute("emailId", "");
|
||||
model.addAttribute("domain", "");
|
||||
model.addAttribute("email", email);
|
||||
model.addAttribute("loginId", email);
|
||||
|
||||
if (parts != null) {
|
||||
model.addAttribute("emailId", parts[0]);
|
||||
model.addAttribute("domain", parts[1]);
|
||||
} else {
|
||||
model.addAttribute("emailId", "");
|
||||
model.addAttribute("domain", "");
|
||||
}
|
||||
|
||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND"));
|
||||
|
||||
return MAIN_USER_REGISTER;
|
||||
|
||||
@@ -139,21 +138,8 @@ public class UserRegisterController {
|
||||
}
|
||||
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
|
||||
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", "회원가입이 완료되었습니다.");
|
||||
return "redirect:/signup/complete";
|
||||
}
|
||||
|
||||
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||
return "redirect:/signup/complete/corporate";
|
||||
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
|
||||
} catch (Exception e) {
|
||||
setModelForError(session, model, agreement, portalUserRegistrationDTO,
|
||||
"처리 중 오류가 발생했습니다: " + e.getMessage());
|
||||
@@ -183,57 +169,6 @@ public class UserRegisterController {
|
||||
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")
|
||||
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
|
||||
HttpSession session,
|
||||
@@ -246,11 +181,6 @@ public class UserRegisterController {
|
||||
String decodedToken = decodeInvitationToken(invitationToken);
|
||||
session.setAttribute("decisionToken", decodedToken);
|
||||
|
||||
// 이미 로그인된 상태면 수락 화면으로 직행
|
||||
if (SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/signup/decision_process";
|
||||
}
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
||||
|
||||
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
|
||||
@@ -258,9 +188,9 @@ public class UserRegisterController {
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
// 휴대폰 번호로 사용자 정보 조회
|
||||
String mobile = invitation.get().getInvitationMobile();
|
||||
Optional<PortalUser> portalUser = portalUserRepository.findAllByMobileNumber(mobile).stream().findFirst();
|
||||
// 이메일로 사용자 정보 조회
|
||||
String email = invitation.get().getInvitationEmail();
|
||||
Optional<PortalUser> portalUser = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||
|
||||
if (!portalUser.isPresent()) {
|
||||
model.addAttribute("error", "사용자 정보를 찾을 수 없습니다.");
|
||||
@@ -289,14 +219,13 @@ public class UserRegisterController {
|
||||
public String showDecisionProcessPage(HttpSession session, Model model) {
|
||||
session.removeAttribute("decisionToken");
|
||||
|
||||
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
|
||||
if (!invitation.isPresent()) {
|
||||
model.addAttribute("error", "유효하지 않은 초대입니다");
|
||||
return "redirect:/";
|
||||
}
|
||||
if (mobile == null || !mobile.equals(invitation.get().getInvitationMobile())) {
|
||||
if (!SecurityUtil.getPortalAuthenticatedUser().getLoginId().equals(invitation.get().getInvitationEmail())) {
|
||||
model.addAttribute("error", "유효하지 않은 초대입니다");
|
||||
return "redirect:/";
|
||||
}
|
||||
@@ -312,9 +241,7 @@ public class UserRegisterController {
|
||||
model.addAttribute("orgName", org.get().getOrgName());
|
||||
model.addAttribute("invitationCode", invitation.get().getToken());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
|
||||
return "apps/register/userDecisionProcess";
|
||||
@@ -333,7 +260,7 @@ public class UserRegisterController {
|
||||
|
||||
agreementValidator.validate(agreement, bindingResult);
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
|
||||
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
|
||||
|
||||
@@ -343,20 +270,13 @@ public class UserRegisterController {
|
||||
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
||||
model.addAttribute("orgName", org.get().getOrgName());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
|
||||
return "apps/register/userDecisionProcess";
|
||||
} else {
|
||||
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("pendingInvitationToken");
|
||||
@@ -374,6 +294,24 @@ public class UserRegisterController {
|
||||
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 void setModelForError(HttpSession session, Model model, UserAgreementDTO agreement,
|
||||
@@ -387,8 +325,7 @@ public class UserRegisterController {
|
||||
model.addAttribute("agreement", agreement);
|
||||
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
|
||||
model.addAttribute("msgType", "sms");
|
||||
}
|
||||
|
||||
@@ -500,10 +437,10 @@ public class UserRegisterController {
|
||||
session.removeAttribute("invitationCodeAttempts");
|
||||
session.removeAttribute("invitationCodeLockoutUntil");
|
||||
|
||||
// 기존 사용자 여부 확인 (휴대폰 번호 기반)
|
||||
boolean existingUser = !portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile()).isEmpty();
|
||||
// 기존 사용자 여부 확인
|
||||
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.isPresent()) {
|
||||
// 기존 사용자: 초대 수락 페이지로 이동
|
||||
return "redirect:/signup/decision?invitation=" + normalizedCode;
|
||||
} else {
|
||||
|
||||
@@ -23,9 +23,6 @@ public class PortalUserDTO implements Serializable {
|
||||
|
||||
private String emailAddr;
|
||||
|
||||
// 초대 시 알림 수신 동의 여부 (false 면 초대 레코드만 생성, 메시지 미발송)
|
||||
private boolean notifyConsent;
|
||||
|
||||
private PortalOrgDTO portalOrg;
|
||||
|
||||
private UserStatus userStatus;
|
||||
|
||||
@@ -192,7 +192,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
// 사용자 생성 및 기관 연결
|
||||
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
|
||||
approvalService.createUserApproval(newUser);
|
||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
@@ -240,7 +240,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
|
||||
portalUserRepository.save(existingUser);
|
||||
agreementsFacade.deleteUserAgreements(existingUser.getId()); //기존 개인약관 동의 삭제
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
|
||||
approvalService.createUserApproval(existingUser);
|
||||
|
||||
@@ -274,7 +274,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
portalUserRepository.save(existingUser);
|
||||
|
||||
// 새로운 법인용 개인정보수집동의서 저장
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
|
||||
approvalService.createUserApproval(existingUser);
|
||||
|
||||
|
||||
@@ -7,10 +7,8 @@ import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.DurationParser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.event.UserInvitationCancelEvent;
|
||||
@@ -20,8 +18,6 @@ import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
@@ -56,9 +52,6 @@ public class UserManFacade {
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final UserLogRepository userLogRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
|
||||
return portalUserRepository
|
||||
@@ -101,35 +94,23 @@ public class UserManFacade {
|
||||
dto.setMobileNumber(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인 개발자 초대 (휴대폰 번호 기반).
|
||||
*
|
||||
* @param sender 초대를 보내는 법인 관리자
|
||||
* @param mobileRaw 초대 대상 휴대폰 번호 (하이픈 유무 무관)
|
||||
* @param notifyConsent 알림 수신 동의 여부. false 면 초대 레코드만 생성하고 메시지 발송은 스킵한다.
|
||||
*/
|
||||
public void sendInvitation(PortalUser sender, String mobileRaw, boolean notifyConsent) {
|
||||
// 휴대폰 번호 형식 검증
|
||||
if (mobileRaw == null || mobileRaw.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("휴대폰 번호를 입력해주세요.");
|
||||
}
|
||||
if (!cellPhoneValidator.isValid(mobileRaw, null)) {
|
||||
throw new IllegalArgumentException("올바른 휴대폰 번호 형식을 입력해주세요.");
|
||||
public void sendInvitation(PortalUser sender, String emailAddr) {
|
||||
// 이메일 형식 검증
|
||||
if (emailAddr == null || emailAddr.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("이메일 주소를 입력해주세요.");
|
||||
}
|
||||
|
||||
// 저장 포맷(하이픈 포함, 예: 010-1234-5678)으로 정규화 — PortalUser.mobileNumber 저장 포맷과
|
||||
// 일치시켜야 결정적 암호화 equality 조회가 매칭된다.
|
||||
String mobile = normalizeMobile(mobileRaw);
|
||||
// 이메일 정규식 검증
|
||||
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
|
||||
if (!emailAddr.matches(emailPattern)) {
|
||||
throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요.");
|
||||
}
|
||||
|
||||
// 중복 초대 확인
|
||||
checkDuplicateInvitation(sender.getPortalOrg().getId(), mobile);
|
||||
checkDuplicateInvitation(sender.getPortalOrg().getId(), emailAddr);
|
||||
|
||||
// 기존 사용자인지 휴대폰 번호로 확인 (mobileNumber 는 유니크가 아니므로 다건 가능)
|
||||
List<PortalUser> matched = portalUserRepository.findAllByMobileNumber(mobile);
|
||||
if (matched.size() > 1) {
|
||||
throw new IllegalArgumentException("해당 휴대폰 번호로 가입된 계정이 여러 건 존재하여 초대할 수 없습니다. 관리자에게 문의해주세요.");
|
||||
}
|
||||
Optional<PortalUser> existingUser = matched.isEmpty() ? Optional.empty() : Optional.of(matched.get(0));
|
||||
//기존 사용자인지 확인
|
||||
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
PortalUser user = existingUser.get();
|
||||
@@ -138,12 +119,7 @@ public class UserManFacade {
|
||||
}
|
||||
}
|
||||
|
||||
String token = createInvitation(sender, mobile);
|
||||
|
||||
// 알림 수신 미동의 시 초대 레코드만 생성하고 메시지 발송은 스킵
|
||||
if (!notifyConsent) {
|
||||
return;
|
||||
}
|
||||
String token = createInvitation(sender, emailAddr);
|
||||
|
||||
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
|
||||
MessageRecipient recipient;
|
||||
@@ -153,7 +129,7 @@ public class UserManFacade {
|
||||
params.put("corpName", sender.getPortalOrg().getOrgName());
|
||||
params.put("authNumber", token); // 8-character invitation code
|
||||
|
||||
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 휴대폰 번호 사용
|
||||
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용
|
||||
if (existingUser.isPresent()) {
|
||||
PortalUser user = existingUser.get();
|
||||
params.put("loginId", user.getUserName());
|
||||
@@ -161,36 +137,26 @@ public class UserManFacade {
|
||||
params.put("url", "signup/decision?invitation=" + token);
|
||||
|
||||
recipient = MessageRecipient.of(user);
|
||||
// MessageRecipient.of() 는 유선전화(phoneNumber)를 채우므로 휴대폰 번호로 보정
|
||||
recipient.setPhone(user.getMobileNumber());
|
||||
} else {
|
||||
params.put("loginId", mobile);
|
||||
params.put("NAME", mobile);
|
||||
params.put("loginId", emailAddr);
|
||||
params.put("NAME", emailAddr);
|
||||
params.put("url", "signup/portalUser?invitation=" + token);
|
||||
|
||||
String emailUsername = emailAddr.contains("@") ? emailAddr.substring(0, emailAddr.indexOf("@")) : emailAddr;
|
||||
recipient = MessageRecipient.builder()
|
||||
.userId(mobile)
|
||||
.username(mobile)
|
||||
.phone(mobile)
|
||||
.userId(emailAddr)
|
||||
.username(emailUsername)
|
||||
.build();
|
||||
}
|
||||
|
||||
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대폰 번호를 저장 포맷(하이픈 포함)으로 정규화한다.
|
||||
* 010-XXXX-XXXX(11자리) / 010-XXX-XXXX(10자리). 형식 불명은 원본 반환(검증에서 차단됨).
|
||||
*/
|
||||
private String normalizeMobile(String raw) {
|
||||
return PhoneNumberUtil.normalize(raw);
|
||||
}
|
||||
|
||||
public String createInvitation(PortalUser sender, String mobile) {
|
||||
public String createInvitation(PortalUser sender, String emailAddr) {
|
||||
UserInvitation newInvitation = new UserInvitation();
|
||||
newInvitation.setAdminId(sender.getId());
|
||||
newInvitation.setOrgId(sender.getPortalOrg().getId());
|
||||
newInvitation.setInvitationMobile(mobile);
|
||||
newInvitation.setInvitationEmail(emailAddr);
|
||||
|
||||
// Set additional required fields
|
||||
newInvitation.setInvitationDate(LocalDateTime.now());
|
||||
@@ -249,10 +215,6 @@ public class UserManFacade {
|
||||
portalUserRepository.save(targetUser);
|
||||
portalUserRepository.save(currentUser);
|
||||
|
||||
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUserId(targetUser.getEmailAddr());
|
||||
recipient.setPhone(targetUser.getMobileNumber());
|
||||
@@ -263,57 +225,7 @@ public class UserManFacade {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인 관리자 권한 회수 (관리자 → 이용자).
|
||||
* 대상은 본인이 아닌 같은 기관의 ROLE_CORP_MANAGER 여야 한다.
|
||||
*/
|
||||
public void revokeManager(PortalAuthenticatedUser admin, String targetUserId) {
|
||||
PortalUser targetUser = portalUserRepository.findById(targetUserId)
|
||||
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
|
||||
|
||||
if (targetUser.getPortalOrg() == null
|
||||
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
|
||||
throw new IllegalArgumentException("잘 못 된 접근입니다");
|
||||
}
|
||||
if (targetUser.getId().equals(admin.getId())) {
|
||||
throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다.");
|
||||
}
|
||||
if (targetUser.getRoleCode() != RoleCode.ROLE_CORP_MANAGER) {
|
||||
throw new IllegalArgumentException("관리자만 이용자로 변경할 수 있습니다.");
|
||||
}
|
||||
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 소속 제외 (기관에서 제외 → 개인이용자).
|
||||
* 대상은 본인이 아닌 같은 기관의 사용자여야 한다. portalOrg 를 비우고 ROLE_USER 로 전환한다.
|
||||
*/
|
||||
public void removeFromOrg(PortalAuthenticatedUser admin, String targetUserId) {
|
||||
PortalUser targetUser = portalUserRepository.findById(targetUserId)
|
||||
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
|
||||
|
||||
if (targetUser.getPortalOrg() == null
|
||||
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
|
||||
throw new IllegalArgumentException("잘 못 된 접근입니다");
|
||||
}
|
||||
if (targetUser.getId().equals(admin.getId())) {
|
||||
throw new IllegalArgumentException("본인은 소속에서 제외할 수 없습니다.");
|
||||
}
|
||||
|
||||
targetUser.setPortalOrg(null);
|
||||
targetUser.setRoleCode(RoleCode.ROLE_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId, boolean notifyConsent) {
|
||||
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId) {
|
||||
UserInvitation invitation = userInvitationRepository.findById(invitationId)
|
||||
.orElseThrow(() -> new NotFoundException("Invitation not found with id: " + invitationId));
|
||||
|
||||
@@ -325,19 +237,17 @@ public class UserManFacade {
|
||||
throw new IllegalStateException("Invitation is not in a cancelable state");
|
||||
}
|
||||
|
||||
invitation.setStatus(InvitationStatus.CANCELED);
|
||||
userInvitationRepository.save(invitation);
|
||||
// invitationId 로 사용자 이름 조회, 실패시 id 를 이름으로 대체
|
||||
String name = portalUserRepository.findById(invitation.getInvitationEmail())
|
||||
.map(PortalUser::getUserName)
|
||||
.orElse(invitation.getInvitationEmail());
|
||||
|
||||
// 알림 수신 미동의 시 취소 알림 메시지 발송은 스킵
|
||||
if (!notifyConsent) {
|
||||
return;
|
||||
// name 이 이메일 일 경우 @ 앞부분 만 사용 (안하면 암호화 해야함...)
|
||||
if (name.contains("@")) {
|
||||
name = name.substring(0, name.indexOf("@"));
|
||||
}
|
||||
|
||||
// invitationMobile 로 사용자 이름 조회, 실패시 마스킹된 번호로 대체
|
||||
String name = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
|
||||
.stream().findFirst()
|
||||
.map(PortalUser::getUserName)
|
||||
.orElse(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
|
||||
invitation.setStatus(InvitationStatus.CANCELED);
|
||||
|
||||
messageHandlerService.publishEvent(UserInvitationCancelEvent.KEY,
|
||||
MessageRecipient.builder()
|
||||
@@ -350,6 +260,8 @@ public class UserManFacade {
|
||||
}}
|
||||
);
|
||||
|
||||
userInvitationRepository.save(invitation);
|
||||
|
||||
}
|
||||
|
||||
public void inactivateUser(PortalAuthenticatedUser admin, String userId) {
|
||||
@@ -386,8 +298,8 @@ public class UserManFacade {
|
||||
.stream()
|
||||
.map(invitation -> {
|
||||
PortalUserDTO dto = portalUserMapper.pendingUserVO(invitation);
|
||||
dto.setMaskedMobileNumber(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
|
||||
dto.setMobileNumber(null); // Clear unmasked mobile
|
||||
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(invitation.getInvitationEmail()));
|
||||
dto.setEmailAddr(null); // Clear unmasked email
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
@@ -416,32 +328,32 @@ public class UserManFacade {
|
||||
existingInvitation.setStatus(InvitationStatus.CANCELED);
|
||||
userInvitationRepository.save(existingInvitation);
|
||||
|
||||
// 새로운 초대 생성 및 발송 (재발송 시 최초 동의값을 알 수 없으므로 발송함을 기본으로)
|
||||
sendInvitation(sender, existingInvitation.getInvitationMobile(), true);
|
||||
// 새로운 초대 생성 및 발송
|
||||
sendInvitation(sender, existingInvitation.getInvitationEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* 중복 초대 확인
|
||||
* 같은 기관에서 같은 휴대폰 번호로 이미 PENDING 상태인 초대가 있는지 확인
|
||||
* 같은 기관에서 같은 이메일로 이미 PENDING 상태인 초대가 있는지 확인
|
||||
*/
|
||||
private void checkDuplicateInvitation(String orgId, String mobile) {
|
||||
private void checkDuplicateInvitation(String orgId, String emailAddr) {
|
||||
// 1. 같은 기관에서 PENDING 상태인 초대가 있는지 확인
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndOrgIdAndStatus(
|
||||
mobile, orgId, InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
|
||||
emailAddr, orgId, InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
throw new IllegalArgumentException("이미 초대가 진행 중인 휴대폰 번호입니다.");
|
||||
throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다.");
|
||||
}
|
||||
|
||||
// 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인
|
||||
Optional<UserInvitation> otherOrgPendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
mobile, InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
||||
emailAddr, InvitationStatus.PENDING);
|
||||
|
||||
if (otherOrgPendingInvitation.isPresent()
|
||||
&& !otherOrgPendingInvitation.get().getOrgId().equals(orgId)) {
|
||||
throw new IllegalArgumentException("해당 휴대폰 번호는 다른 기관에서 초대 진행 중입니다.");
|
||||
throw new IllegalArgumentException("해당 이메일은 다른 기관에서 초대 진행 중입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-17
@@ -137,11 +137,12 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false, "이미 사용 중인 이메일입니다.");
|
||||
}
|
||||
|
||||
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
// 25.10.01 - 휴대폰 번호 중복이여도 가입 가능
|
||||
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
|
||||
//
|
||||
// if(existingUser != null) {
|
||||
// return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||
// }
|
||||
|
||||
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
|
||||
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
|
||||
@@ -149,7 +150,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false,"사용자 등록에 실패했습니다.");
|
||||
}
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_IND);
|
||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
// sendEmailActivation(newUser);
|
||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||
@@ -172,12 +173,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false,"입력값을 확인해 주세요.");
|
||||
}
|
||||
|
||||
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
|
||||
|
||||
if(existingUser != null) {
|
||||
@@ -195,7 +190,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
}
|
||||
|
||||
agreementsFacade.deleteUserAgreements(newUser.getId()); //이전에 동의한 내역은 삭제
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
|
||||
invitation.setStatus(UserInvitationEnums.InvitationStatus.COMPLETED);
|
||||
invitation.setCompleteDate(LocalDateTime.now());
|
||||
@@ -247,8 +242,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
try {
|
||||
if ("accept".equals(action)) {
|
||||
// 수락 처리
|
||||
PortalUser user = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
|
||||
.stream().findFirst()
|
||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail())
|
||||
.orElseThrow(() -> new IllegalArgumentException("사용자 정보를 찾을 수 없습니다."));
|
||||
|
||||
// 서비스 계층에 위임
|
||||
@@ -259,11 +253,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
userInvitationRepository.save(invitation);
|
||||
|
||||
agreementsFacade.deleteUserAgreements(user.getId());
|
||||
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
|
||||
ValidationResponse response = new ValidationResponse();
|
||||
response.setValid(true);
|
||||
response.setMessage("초대가 성공적으로 수락되었습니다. 법인회원으로 전환되었습니다.");
|
||||
response.setMessage("초대가 성공적으로 수락되었습니다. 로그아웃 후 다시 로그인 해 주세요.");
|
||||
response.setShowPopup(true);
|
||||
response.setPopupType("success");
|
||||
return response;
|
||||
|
||||
@@ -15,7 +15,7 @@ public abstract class PortalUserMapper implements GenericMapper<PortalUserDTO, P
|
||||
|
||||
public abstract PortalAuthenticatedUser portalUserToAuthenticatedUser(PortalUser portalUser);
|
||||
|
||||
@Mapping(target = "mobileNumber", source = "invitationMobile")
|
||||
@Mapping(target = "emailAddr", source = "invitationEmail")
|
||||
public abstract PortalUserDTO pendingUserVO(UserInvitation invitation);
|
||||
|
||||
public PortalUserDTO toDTO(PortalUser portalUser) {
|
||||
|
||||
+41
-38
@@ -6,7 +6,6 @@ import com.eactive.apim.portal.common.exception.SystemException;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
@@ -31,9 +30,7 @@ import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
@@ -61,46 +58,21 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
|
||||
String normalizedUsername = username != null ? username.toLowerCase() : null;
|
||||
PortalUser portalUser = findByEmailAddr(normalizedUsername);
|
||||
return buildAuthenticatedUser(portalUser);
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
|
||||
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
|
||||
|
||||
return authenticatedUser;
|
||||
|
||||
} catch (UserNotFoundException e) {
|
||||
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB의 PortalUser 로부터 권한(roleCode + 역할 계층)을 채운 PortalAuthenticatedUser 를 생성한다.
|
||||
* 로그인과 세션 재로드가 동일한 권한 구성을 사용하도록 공통화한다.
|
||||
*/
|
||||
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
|
||||
|
||||
return authenticatedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 권한을 DB에서 다시 읽어 SecurityContext 의 Authentication 을 교체한다.
|
||||
* 재로그인 없이 본인 세션에 역할 변경(초대 수락, 관리자 위임으로 인한 본인 강등 등)을 즉시 반영한다.
|
||||
*/
|
||||
public void reloadCurrentAuthentication() {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
PortalUser fresh = portalUserRepository.findByLoginId(current.getLoginId())
|
||||
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
|
||||
PortalAuthenticatedUser refreshed = buildAuthenticatedUser(fresh);
|
||||
|
||||
UsernamePasswordAuthenticationToken newAuth =
|
||||
new UsernamePasswordAuthenticationToken(refreshed, null, refreshed.getAuthorities());
|
||||
newAuth.setDetails(refreshed);
|
||||
SecurityContextHolder.getContext().setAuthentication(newAuth);
|
||||
}
|
||||
|
||||
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
|
||||
try {
|
||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
@@ -150,6 +122,37 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
messageHandlerService.publishEvent(UserPasswordResetEvent.KEY, recipient, params);
|
||||
}
|
||||
|
||||
public void resendVerificationEmail(String loginId) {
|
||||
|
||||
PortalUser user = portalUserRepository.findByLoginId(loginId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
|
||||
|
||||
if (user.getUserStatus().equals(PortalUserEnums.UserStatus.ACTIVE)) {
|
||||
throw new IllegalArgumentException("이미 인증이 완료된 계정입니다.");
|
||||
}
|
||||
|
||||
// Enum을 직접 전달
|
||||
List<MessageRequest> messageRequests = messageRequestRepository.findByEmailAndMessageCode(
|
||||
user.getLoginId(), MessageCode.USER_VERIFICATION_EMAIL
|
||||
);
|
||||
|
||||
if (!messageRequests.isEmpty()) {
|
||||
messageRequestRepository.deleteAll(messageRequests);
|
||||
}
|
||||
|
||||
HashMap<String, Object> params = new HashMap<>();
|
||||
String tokenValue = user.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + user.getId();
|
||||
|
||||
try {
|
||||
String encToken = encryptionUtil.encrypt(tokenValue);
|
||||
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
||||
MessageRecipient recipient = createMessageRecipient(user);
|
||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new SystemException("암호화 모듈 오류");
|
||||
}
|
||||
}
|
||||
|
||||
private MessageRecipient createMessageRecipient(PortalUser user) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUsername(user.getUserName());
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
@@ -25,12 +24,6 @@ import org.springframework.util.StringUtils;
|
||||
@Service
|
||||
@Transactional
|
||||
public class PortalUserService {
|
||||
|
||||
/** PortalProperty 그룹명 */
|
||||
private static final String PORTAL_PROPERTY_GROUP = "Portal";
|
||||
/** 휴대폰 번호 중복 허용 프로퍼티 키 (true:허용, false:허용안함, 기본 false) */
|
||||
private static final String PROP_MOBILE_DUPLICATE_ALLOW = "user.mobile.duplicate.allow";
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
@@ -75,33 +68,6 @@ public class PortalUserService {
|
||||
return portalUserRepository.existsByLoginId(normalizedLoginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대폰 번호 중복 여부 확인.
|
||||
* mobileNumber 는 저장 포맷(하이픈 포함, 예: 010-1234-5678)과 동일한 형태로 전달해야 한다.
|
||||
* (암호화 컬럼이므로 결정적 암호화 기준 평등 조회로 매칭된다)
|
||||
*/
|
||||
public boolean existsByMobileNumber(String mobileNumber) {
|
||||
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return portalUserRepository.existsByMobileNumber(PhoneNumberUtil.normalize(mobileNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 시 휴대폰 번호 중복 차단 활성 여부.
|
||||
* 공유 프로퍼티(Portal/user.mobile.duplicate.allow)를 true/false 로 해석한다.
|
||||
* - allow=true(또는 레거시 Y): 중복 허용 → 차단 비활성
|
||||
* - allow=false(또는 레거시 N) / 미설정: 중복 허용안함 → 차단 활성 (기본값 false)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isMobileDuplicateCheckEnabled() {
|
||||
String allow = portalPropertyService
|
||||
.getPortalPropertiesAsMap(PORTAL_PROPERTY_GROUP)
|
||||
.getOrDefault(PROP_MOBILE_DUPLICATE_ALLOW, "false");
|
||||
boolean allowed = "true".equalsIgnoreCase(allow) || "Y".equalsIgnoreCase(allow);
|
||||
return !allowed;
|
||||
}
|
||||
|
||||
public boolean checkOrgHasOtherUsers(PortalOrg portalOrg) {
|
||||
return portalUserRepository.countByPortalOrg(portalOrg) > 1;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
|
||||
@ControllerAdvice
|
||||
public class GlobalControllerAdvice {
|
||||
@@ -22,9 +21,6 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private PortalProperties portalProperties;
|
||||
|
||||
@Autowired
|
||||
private UserSessionService userSessionService;
|
||||
|
||||
@ModelAttribute("breadcrumb")
|
||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||
String currentPath = request.getRequestURI();
|
||||
@@ -62,12 +58,4 @@ public class GlobalControllerAdvice {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("sessionTimeoutMinutes")
|
||||
public int sessionTimeoutMinutes() {
|
||||
return userSessionService.getSessionTimeoutMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.eactive.apim.portal.common.exception;
|
||||
|
||||
import org.springframework.boot.web.servlet.error.ErrorController;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -11,8 +9,6 @@ import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import javax.servlet.RequestDispatcher;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
@@ -20,11 +16,6 @@ import java.io.StringWriter;
|
||||
@Controller
|
||||
public class PortalErrorController implements ErrorController {
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public PortalErrorController(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@RequestMapping(value = "/error", method = {RequestMethod.POST, RequestMethod.GET})
|
||||
public ModelAndView handleError(HttpServletRequest request) {
|
||||
@@ -36,134 +27,10 @@ public class PortalErrorController implements ErrorController {
|
||||
}
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("status", status);
|
||||
|
||||
if (isProd()) {
|
||||
// prod 환경에서는 내부 오류 정보를 노출하지 않고 이전과 동일한 일반 안내 메시지만 표시
|
||||
modelAndView.addObject("errorTitle", "일시적인 장애로 서비스가 중단되었습니다.");
|
||||
modelAndView.addObject("errorDescription", "서비스 이용에 불편을 드려 죄송합니다. 빠른 서비스를 제공할 수 있게 준비하겠습니다.");
|
||||
} else {
|
||||
modelAndView.addObject("errorTitle", resolveTitle(status));
|
||||
modelAndView.addObject("errorDescription", resolveDescription(status));
|
||||
modelAndView.addObject("errorMessage", resolveErrorMessage(request, status));
|
||||
modelAndView.addObject("activeProfile", resolveActiveProfile());
|
||||
// include-stacktrace=always(로컬 디버깅 프로파일)일 때만 화면에 스택 트레이스까지 노출
|
||||
if (isErrorDetailEnabled()) {
|
||||
Throwable throwable = resolveThrowable(request);
|
||||
if (throwable != null) {
|
||||
modelAndView.addObject("errorException", throwable.getClass().getName());
|
||||
modelAndView.addObject("errorStackTrace", stackTraceAsString(throwable));
|
||||
}
|
||||
}
|
||||
}
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/");
|
||||
}
|
||||
|
||||
private boolean isProd() {
|
||||
return environment.acceptsProfiles(Profiles.of("prod"));
|
||||
}
|
||||
|
||||
/**
|
||||
* server.error.include-stacktrace=always 인 경우에만 화면에 스택 트레이스를 노출한다.
|
||||
* (로컬 디버깅 프로파일에서만 해당 값을 always 로 설정 → 운영/스테이징에는 노출되지 않음)
|
||||
*/
|
||||
private boolean isErrorDetailEnabled() {
|
||||
String includeStacktrace = environment.getProperty("server.error.include-stacktrace", "never");
|
||||
return "always".equalsIgnoreCase(includeStacktrace);
|
||||
}
|
||||
|
||||
private String resolveActiveProfile() {
|
||||
String[] activeProfiles = environment.getActiveProfiles();
|
||||
if (activeProfiles.length == 0) {
|
||||
return "default";
|
||||
}
|
||||
return String.join(", ", activeProfiles);
|
||||
}
|
||||
|
||||
private Throwable resolveThrowable(HttpServletRequest request) {
|
||||
Object exception = request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
|
||||
if (exception instanceof Throwable) {
|
||||
return (Throwable) exception;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String stackTraceAsString(Throwable throwable) {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
throwable.printStackTrace(new PrintWriter(stringWriter));
|
||||
return stringWriter.toString();
|
||||
}
|
||||
|
||||
private String resolveTitle(Object status) {
|
||||
HttpStatus httpStatus = resolveHttpStatus(status);
|
||||
if (httpStatus == null) {
|
||||
return "서비스 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
if (httpStatus == HttpStatus.FORBIDDEN) {
|
||||
return "접근이 거부되었습니다.";
|
||||
}
|
||||
if (httpStatus == HttpStatus.NOT_FOUND) {
|
||||
return "요청하신 페이지를 찾을 수 없습니다.";
|
||||
}
|
||||
if (httpStatus.is4xxClientError()) {
|
||||
return "요청을 처리할 수 없습니다.";
|
||||
}
|
||||
if (httpStatus.is5xxServerError()) {
|
||||
return "서비스 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
return httpStatus.getReasonPhrase();
|
||||
}
|
||||
|
||||
private String resolveDescription(Object status) {
|
||||
HttpStatus httpStatus = resolveHttpStatus(status);
|
||||
if (httpStatus == HttpStatus.FORBIDDEN) {
|
||||
return "요청이 보안 검증을 통과하지 못했습니다.";
|
||||
}
|
||||
if (httpStatus == HttpStatus.NOT_FOUND) {
|
||||
return "주소가 변경되었거나 삭제되었을 수 있습니다.";
|
||||
}
|
||||
if (httpStatus != null && httpStatus.is5xxServerError()) {
|
||||
return "잠시 후 다시 시도해 주세요.";
|
||||
}
|
||||
return "요청 내용을 확인한 뒤 다시 시도해 주세요.";
|
||||
}
|
||||
|
||||
private String resolveErrorMessage(HttpServletRequest request, Object status) {
|
||||
Object requestUri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
|
||||
HttpStatus httpStatus = resolveHttpStatus(status);
|
||||
StringBuilder message = new StringBuilder();
|
||||
|
||||
message.append("HTTP ").append(status);
|
||||
if (httpStatus != null) {
|
||||
message.append(" ").append(httpStatus.getReasonPhrase());
|
||||
}
|
||||
|
||||
if (requestUri != null) {
|
||||
message.append(" / 요청 경로: ").append(requestUri);
|
||||
}
|
||||
|
||||
Object exceptionMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
|
||||
if (exceptionMessage != null && !exceptionMessage.toString().trim().isEmpty()) {
|
||||
message.append(" / 원인: ").append(exceptionMessage);
|
||||
} else if (httpStatus == HttpStatus.FORBIDDEN) {
|
||||
message.append(" / 원인: 접근 권한 또는 CSRF 토큰을 확인하세요.");
|
||||
}
|
||||
|
||||
return message.toString();
|
||||
}
|
||||
|
||||
private HttpStatus resolveHttpStatus(Object status) {
|
||||
if (status instanceof Integer) {
|
||||
return HttpStatus.resolve((Integer) status);
|
||||
}
|
||||
try {
|
||||
return HttpStatus.resolve(Integer.parseInt(status.toString()));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-194
@@ -1,194 +0,0 @@
|
||||
package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
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 org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
||||
*
|
||||
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
|
||||
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
|
||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
||||
*
|
||||
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
|
||||
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* # 미리보기(변경 안 함)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
||||
* # 실제 실행 (PII 컬럼)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
||||
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/internal/migration")
|
||||
public class LegacyEncryptionMigrationController {
|
||||
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
|
||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
|
||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
|
||||
);
|
||||
|
||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
||||
private static final List<String> AUDIT_TABLES = Arrays.asList(
|
||||
"PTL_USER", "ptl_faq", "ptl_org",
|
||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
|
||||
);
|
||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
||||
|
||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
|
||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
||||
}
|
||||
|
||||
@PostMapping("/encrypt-legacy")
|
||||
@Transactional("transactionManager")
|
||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||
assertLocalOnly(request);
|
||||
assertNotBypass();
|
||||
|
||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||
if (includeAudit) {
|
||||
for (String table : AUDIT_TABLES) {
|
||||
targets.add(new TargetTable(table, AUDIT_COLUMNS));
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int totalChanged = 0;
|
||||
for (TargetTable target : targets) {
|
||||
for (String column : target.columns) {
|
||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
||||
results.add(r);
|
||||
totalChanged += (int) r.get("changed");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
||||
response.put("includeAudit", includeAudit);
|
||||
response.put("totalChanged", totalChanged);
|
||||
response.put("results", results);
|
||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
|
||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
||||
*/
|
||||
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("table", table);
|
||||
r.put("column", column);
|
||||
|
||||
List<String> values;
|
||||
try {
|
||||
values = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
|
||||
String.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
||||
r.put("distinct", 0);
|
||||
r.put("changed", 0);
|
||||
r.put("error", e.getMessage());
|
||||
return r;
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
for (String value : values) {
|
||||
String normalized;
|
||||
try {
|
||||
// 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등)
|
||||
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
||||
continue;
|
||||
}
|
||||
if (normalized != null && !normalized.equals(value)) {
|
||||
if (!dryRun) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
|
||||
normalized, value);
|
||||
}
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
r.put("distinct", values.size());
|
||||
r.put("changed", changed);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* damo-manager 가 bypass 모드면 실행 자체를 거부한다.
|
||||
* bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어
|
||||
* 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다.
|
||||
*/
|
||||
private void assertNotBypass() {
|
||||
if (converter.isBypassMode()) {
|
||||
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
|
||||
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||
*/
|
||||
private void assertLocalOnly(HttpServletRequest request) {
|
||||
String remote = request.getRemoteAddr();
|
||||
boolean localAddr = "127.0.0.1".equals(remote)
|
||||
|| "0:0:0:0:0:0:0:1".equals(remote)
|
||||
|| "::1".equals(remote);
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
if (!localAddr || viaProxy) {
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For"));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TargetTable {
|
||||
final String table;
|
||||
final List<String> columns;
|
||||
|
||||
TargetTable(String table, List<String> columns) {
|
||||
this.table = table;
|
||||
this.columns = columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,10 @@
|
||||
package com.eactive.apim.portal.common.util;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 문자열 마스킹 처리를 위한 유틸리티 클래스
|
||||
*
|
||||
* <p>보안아키텍처 표준 마스킹 규칙(elink-online-core {@code MaskingUtils})과 동일한 알고리즘을
|
||||
* 구현한다. eapim-portal은 elink-online-core에 의존하지 않으므로 로직을 직접 포함한다.
|
||||
* 소유자 본인이 자신의 정보를 조회하는 경우 마스킹하지 않는 owner 인지 동작은 유지한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* 이름 2자: 뒤 1자리(가나→가*) / 3자 이상: 앞뒤 제외 가운데 전체(가나다라→가**라)
|
||||
* 이메일 아이디 앞2·뒤2 마스킹, 4자 이하 전체(test→****, test123→**st1**)
|
||||
* 전화 2·3번째 세그먼트 각각 뒤 2자리(010-1234-1234→010-12**-12**)
|
||||
* </pre>
|
||||
*/
|
||||
public class StringMaskingUtil {
|
||||
|
||||
private static final char MASK = '*';
|
||||
|
||||
// ID 비교
|
||||
private static boolean isOwner(String ownerId, String currentUserId) {
|
||||
return currentUserId != null && ownerId != null && currentUserId.equals(ownerId);
|
||||
@@ -35,15 +21,8 @@ public class StringMaskingUtil {
|
||||
return name;
|
||||
}
|
||||
|
||||
int len = name.length();
|
||||
if (len == 1) {
|
||||
return name;
|
||||
}
|
||||
if (len == 2) {
|
||||
return name.charAt(0) + String.valueOf(MASK);
|
||||
}
|
||||
// 3자 이상: 첫 자 + 가운데 전체 마스킹 + 마지막 자
|
||||
return name.charAt(0) + stars(len - 2) + name.charAt(len - 1);
|
||||
return name.length() > 1 ?
|
||||
name.substring(0, name.length() - 1) + "*" : name;
|
||||
}
|
||||
|
||||
// 이메일 마스킹 처리
|
||||
@@ -52,32 +31,32 @@ public class StringMaskingUtil {
|
||||
return email;
|
||||
}
|
||||
|
||||
int atIdx = email.indexOf('@');
|
||||
String local = email.substring(0, atIdx);
|
||||
String domain = email.substring(atIdx);
|
||||
|
||||
if (local.length() <= 4) {
|
||||
return stars(local.length()) + domain;
|
||||
String[] parts = email.split("@", 2);
|
||||
if (parts.length != 2) {
|
||||
return email;
|
||||
}
|
||||
return stars(2) + local.substring(2, local.length() - 2) + stars(2) + domain;
|
||||
|
||||
String localPart = parts[0];
|
||||
String maskedLocal;
|
||||
|
||||
if (localPart.length() <= 2) {
|
||||
maskedLocal = "***";
|
||||
} else {
|
||||
maskedLocal = localPart.substring(0, localPart.length() - 3) + "***";
|
||||
}
|
||||
|
||||
return maskedLocal + "@" + parts[1];
|
||||
}
|
||||
|
||||
// 휴대폰/전화/FAX 번호 마스킹 처리
|
||||
// 휴대폰 번호 마스킹 처리
|
||||
public static String maskMobileNumber(String number, String ownerId, String currentUserId) {
|
||||
if (!isValidString(number) || isOwner(ownerId, currentUserId)) {
|
||||
return number;
|
||||
}
|
||||
|
||||
boolean hasHyphen = number.contains("-");
|
||||
String normalized = hasHyphen ? number : normalizePhoneNumber(number);
|
||||
String[] parts = normalized.split("-");
|
||||
if (parts.length == 3) {
|
||||
String m0 = parts[0];
|
||||
String m1 = maskSegmentTail(parts[1], 2);
|
||||
String m2 = maskSegmentTail(parts[2], 2);
|
||||
return hasHyphen ? m0 + "-" + m1 + "-" + m2 : m0 + m1 + m2;
|
||||
}
|
||||
return number;
|
||||
String[] parts = number.split("-", 3);
|
||||
return parts.length == 3 ?
|
||||
parts[0] + "-***-" + parts[2] : number;
|
||||
}
|
||||
|
||||
// 기존 메서드 오버로딩 (하위 호환성)
|
||||
@@ -92,41 +71,4 @@ public class StringMaskingUtil {
|
||||
public static String maskMobileNumber(String number) {
|
||||
return maskMobileNumber(number, null, null);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// private helpers
|
||||
// =========================================================================
|
||||
|
||||
private static String stars(int count) {
|
||||
if (count <= 0) return "";
|
||||
char[] arr = new char[count];
|
||||
Arrays.fill(arr, MASK);
|
||||
return new String(arr);
|
||||
}
|
||||
|
||||
private static String maskSegmentTail(String segment, int count) {
|
||||
if (segment.length() <= count) return stars(segment.length());
|
||||
return segment.substring(0, segment.length() - count) + stars(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 전화번호(숫자만) → XXX-XXXX-XXXX 형식으로 정규화
|
||||
* <pre>
|
||||
* 02 + 7자리 → 02-XXX-XXXX
|
||||
* 02 + 8자리 → 02-XXXX-XXXX
|
||||
* 0XX + 7자리 → 0XX-XXX-XXXX
|
||||
* 0XX + 8자리 → 0XX-XXXX-XXXX
|
||||
* </pre>
|
||||
*/
|
||||
private static String normalizePhoneNumber(String digits) {
|
||||
int len = digits.length();
|
||||
if (digits.startsWith("02")) {
|
||||
if (len == 9) return digits.substring(0, 2) + "-" + digits.substring(2, 5) + "-" + digits.substring(5);
|
||||
if (len == 10) return digits.substring(0, 2) + "-" + digits.substring(2, 6) + "-" + digits.substring(6);
|
||||
} else if (digits.startsWith("0")) {
|
||||
if (len == 10) return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
|
||||
if (len == 11) return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-12
@@ -1,10 +1,8 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
@@ -51,7 +49,6 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
@@ -96,10 +93,9 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
// 휴대폰 형식(하이픈 유무)이 달라도 초대와 매칭되도록 정규화 후 조회
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
||||
user.getLoginId(), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
@@ -116,12 +112,6 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
logLoginSuccess(request, session, username);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
||||
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
@@ -11,12 +10,10 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.session.SessionRegistry;
|
||||
import org.springframework.security.core.session.SessionRegistryImpl;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CsrfException;
|
||||
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.session.HttpSessionEventPublisher;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
|
||||
@@ -36,30 +33,15 @@ public class PortalConfigSecurity {
|
||||
|
||||
private final PortalLogoutSuccessHandler logoutSuccessHandler;
|
||||
|
||||
private final SessionValidationFilter sessionValidationFilter;
|
||||
|
||||
@Autowired
|
||||
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
|
||||
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
|
||||
PortalAuthenticationManager portalAuthenticationManager,
|
||||
PortalLogoutSuccessHandler logoutSuccessHandler,
|
||||
SessionValidationFilter sessionValidationFilter) {
|
||||
PortalLogoutSuccessHandler logoutSuccessHandler) {
|
||||
this.authenticationFailureHandler = authenticationFailureHandler;
|
||||
this.authenticationSuccessHandler = authenticationSuccessHandler;
|
||||
this.portalAuthenticationManager = portalAuthenticationManager;
|
||||
this.logoutSuccessHandler = logoutSuccessHandler;
|
||||
this.sessionValidationFilter = sessionValidationFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link SessionValidationFilter}가 Spring Security 필터 체인에서만 동작하도록
|
||||
* 서블릿 컨테이너의 자동 등록을 비활성화한다.
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<SessionValidationFilter> sessionValidationFilterRegistration(SessionValidationFilter filter) {
|
||||
FilterRegistrationBean<SessionValidationFilter> registration = new FilterRegistrationBean<>(filter);
|
||||
registration.setEnabled(false);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -82,13 +64,6 @@ public class PortalConfigSecurity {
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) {
|
||||
try {
|
||||
// CSRF 토큰을 쿠키(XSRF-TOKEN) 대신 HTTP 세션에 저장한다.
|
||||
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
||||
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
||||
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
||||
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
||||
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
||||
|
||||
http
|
||||
.authenticationManager(portalAuthenticationManager)
|
||||
.formLogin(form -> form
|
||||
@@ -100,28 +75,21 @@ public class PortalConfigSecurity {
|
||||
.successHandler(authenticationSuccessHandler))
|
||||
.logout(logout -> logout
|
||||
.logoutRequestMatcher(new AntPathRequestMatcher("/actionLogout.do", "GET"))
|
||||
// LogoutHandler 는 기본 SecurityContextLogoutHandler(세션 무효화)보다 먼저 실행된다.
|
||||
// 세션이 살아있는 이 시점에 DB 세션 레코드를 정리해야 중복세션 오탐("이미 접속중")을 막는다.
|
||||
.addLogoutHandler(logoutSuccessHandler)
|
||||
.logoutSuccessHandler(logoutSuccessHandler))
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(csrfTokenRepository)
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||
)
|
||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||
// 익명으로 인식되지 못하고 기본 403("권한없음")으로 떨어진다.
|
||||
// CSRF 만료는 권한 문제가 아니라 세션 만료이므로 기존 안내(/login?expired=true)로 보낸다.
|
||||
.exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler()))
|
||||
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.maximumSessions(1) // Allow only one session per user
|
||||
.maxSessionsPreventsLogin(false) // Prevent new login when session limit is reached
|
||||
.expiredUrl("/login?expired") // Redirect when session expires
|
||||
.sessionRegistry(sessionRegistry()) // Session registry bean to track sessions
|
||||
.and()
|
||||
.sessionFixation()
|
||||
.changeSessionId()
|
||||
)
|
||||
.addFilterBefore(sessionValidationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
);
|
||||
|
||||
return http.build();
|
||||
} catch (Exception e) {
|
||||
@@ -129,21 +97,9 @@ public class PortalConfigSecurity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF 토큰 누락/불일치는 "권한없음"이 아니라 (대개) 세션 만료다.
|
||||
* 로그인 페이지에 오래 머물러 세션 내 CSRF 토큰이 사라진 경우가 대표적이므로
|
||||
* 기존 세션 만료 안내(login.html의 {@code param.expired})를 재사용해 오해를 막는다.
|
||||
* 그 외 진짜 권한 위반(@Secured 등)은 기본 핸들러에 위임해 403을 유지한다.
|
||||
*/
|
||||
private AccessDeniedHandler csrfAwareAccessDeniedHandler() {
|
||||
AccessDeniedHandlerImpl delegate = new AccessDeniedHandlerImpl();
|
||||
return (request, response, accessDeniedException) -> {
|
||||
if (accessDeniedException instanceof CsrfException) {
|
||||
response.sendRedirect(request.getContextPath() + "/login?expired=true");
|
||||
return;
|
||||
}
|
||||
delegate.handle(request, response, accessDeniedException);
|
||||
};
|
||||
@Bean
|
||||
public SessionRegistry sessionRegistry() {
|
||||
return new SessionRegistryImpl();
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -5,14 +5,11 @@ import com.eactive.apim.portal.common.converter.EnabledStatusConverter;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.boot.web.servlet.ServletComponentScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
|
||||
import org.springframework.format.FormatterRegistry;
|
||||
@@ -21,13 +18,10 @@ import org.springframework.web.filter.CharacterEncodingFilter;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.multipart.support.MultipartFilter;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
|
||||
import org.springframework.web.servlet.resource.VersionResourceResolver;
|
||||
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
|
||||
|
||||
|
||||
@@ -38,22 +32,6 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
public static final String ERROR = "error";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
|
||||
@Value("${app.resource-versioning.enabled:true}")
|
||||
private boolean resourceVersioningEnabled;
|
||||
|
||||
// 정적자원 서버측 캐싱(ResourceChain 캐시) 토글(application.yml: app.resource-caching.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceCachingEnabled 참고).
|
||||
// 개발환경에서 OFF 면 sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
|
||||
@Value("${app.resource-caching.enabled:false}")
|
||||
private boolean resourceCachingEnabled;
|
||||
|
||||
public PortalConfigWebDispatcherServlet(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
@@ -83,64 +61,16 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
|
||||
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
|
||||
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
|
||||
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
|
||||
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
|
||||
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
|
||||
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
|
||||
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
|
||||
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적자원 핸들러 공통 등록.
|
||||
*
|
||||
* <p>콘텐츠 해시 버전닝이 켜져 있으면 {@link VersionResourceResolver} 를 체인 앞단에 추가하여
|
||||
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
||||
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
||||
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
||||
*/
|
||||
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
||||
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
|
||||
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
|
||||
CacheControl cacheControl = isResourceCachingEnabled()
|
||||
? CacheControl.maxAge(1, TimeUnit.DAYS)
|
||||
: CacheControl.noStore();
|
||||
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
||||
.addResourceLocations(locations)
|
||||
.setCacheControl(cacheControl)
|
||||
.resourceChain(isResourceCachingEnabled());
|
||||
if (isResourceVersioningEnabled()) {
|
||||
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
||||
}
|
||||
chain.addResolver(new PathResourceResolver());
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
|
||||
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
|
||||
*/
|
||||
private boolean isResourceVersioningEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
return resourceVersioningEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적자원 서버측 캐싱(ResourceChain 의 CachingResourceResolver) 활성 여부.
|
||||
* prod 프로파일은 토글과 무관하게 항상 ON, 그 외 프로파일은 {@code app.resource-caching.enabled} 값을 따른다.
|
||||
*
|
||||
* <p>OFF 면 매 요청마다 리소스를 재해석하므로, sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
|
||||
* ({@code spring.web.resources.cache} 는 브라우저 캐시 헤더만 제어할 뿐 이 서버측 캐시와는 무관하다.)</p>
|
||||
*/
|
||||
private boolean isResourceCachingEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
return resourceCachingEnabled;
|
||||
registry.addResourceHandler("/css/**").addResourceLocations("/css/", "classpath:/static/css/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/webfonts/**").addResourceLocations("/webfonts/", "classpath:/static/webfonts/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/font/**").addResourceLocations("/font/", "classpath:/static/font/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/html/**").addResourceLocations("/html/", "classpath:/static/html/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/images/**").addResourceLocations("/images/", "classpath:/static/images/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/plugins/**").addResourceLocations("/plugins/", "classpath:/static/plugins/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/favicon.ico").addResourceLocations("/favicon.ico", "classpath:/static/favicon.ico").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
}
|
||||
|
||||
|
||||
@@ -193,13 +123,4 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
registrationBean.addUrlPatterns("/*");
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thymeleaf {@code @{...}} 링크를 해시 버전 URL 로 치환하기 위한 필터.
|
||||
* {@code @EnableWebMvc} 환경에서는 자동 등록되지 않으므로 명시적으로 빈 등록한다.
|
||||
*/
|
||||
@Bean
|
||||
public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
|
||||
return new ResourceUrlEncodingFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
||||
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -20,37 +18,20 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Enumeration;
|
||||
|
||||
/**
|
||||
* 로그아웃 처리 핸들러.
|
||||
*
|
||||
* <p>DB 세션 레코드 정리와 로깅은 {@link LogoutHandler#logout}에서 수행한다. 이 시점은 기본
|
||||
* {@code SecurityContextLogoutHandler}가 HTTP 세션을 무효화하기 <b>전</b>이라 세션이 유효하다.
|
||||
* (성공 핸들러 {@link #onLogoutSuccess}는 무효화 <b>후</b>에 실행되어 {@code getSession(false)}가
|
||||
* null 이 되므로, 거기서 정리하면 DB 행이 남아 재로그인 시 "이미 접속중" 오탐이 발생한다.)
|
||||
* 로그아웃 성공 시 세션 정보를 로깅하는 핸들러
|
||||
*/
|
||||
@Component
|
||||
public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessHandler {
|
||||
public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
|
||||
|
||||
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
public PortalLogoutSuccessHandler(UserSessionService userSessionService) {
|
||||
this.userSessionService = userSessionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 무효화 전에 실행되어 DB 세션 레코드를 정리하고 로그아웃 정보를 로깅한다.
|
||||
*/
|
||||
@Override
|
||||
public void logout(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) {
|
||||
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
HttpSession session = request.getSession(false);
|
||||
|
||||
if (session != null) {
|
||||
// DB 세션 레코드 정리 (중복세션 오탐 방지)
|
||||
userSessionService.removeSession(session.getId());
|
||||
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
@@ -116,14 +97,7 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃(세션 무효화) 완료 후 메인으로 리다이렉트한다.
|
||||
*/
|
||||
@Override
|
||||
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
response.sendRedirect(request.getContextPath() + "/");
|
||||
}
|
||||
}
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.controller;
|
||||
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
|
||||
import com.eactive.apim.portal.djb.community.qna.exception.InquiryClosedException;
|
||||
import com.eactive.apim.portal.djb.community.qna.exception.InquiryCommentNotOwnedException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/djb/inquiry")
|
||||
@Secured("ROLE_INQUIRY")
|
||||
@RequiredArgsConstructor
|
||||
public class InquiryCommentController {
|
||||
|
||||
private final InquiryCommentFacade inquiryCommentFacade;
|
||||
|
||||
@GetMapping("/{inquiryId}/comments")
|
||||
public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
return ResponseEntity.ok(inquiryCommentFacade.getComments(inquiryId, current));
|
||||
}
|
||||
|
||||
@PostMapping("/{inquiryId}/comments")
|
||||
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
|
||||
@Valid @RequestBody InquiryCommentCreateRequest request) {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(inquiryId, request.getContent(), current);
|
||||
return ResponseEntity.ok(created);
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{commentId}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String commentId) {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
inquiryCommentFacade.deleteOwnComment(commentId, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@ExceptionHandler(InquiryClosedException.class)
|
||||
public ResponseEntity<Map<String, String>> handleClosed(InquiryClosedException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorBody("INQUIRY_CLOSED", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(InquiryCommentNotOwnedException.class)
|
||||
public ResponseEntity<Map<String, String>> handleNotOwned(InquiryCommentNotOwnedException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage()));
|
||||
}
|
||||
|
||||
private Map<String, String> errorBody(String code, String message) {
|
||||
Map<String, String> body = new HashMap<>();
|
||||
body.put("code", code);
|
||||
body.put("message", message);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.NotBlank;
|
||||
|
||||
import javax.validation.constraints.Size;
|
||||
|
||||
@Data
|
||||
public class InquiryCommentCreateRequest {
|
||||
|
||||
@NotBlank(message = "댓글 내용을 입력해주세요.")
|
||||
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
|
||||
private String content;
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InquiryCommentDTO {
|
||||
|
||||
private String id;
|
||||
|
||||
private String content;
|
||||
|
||||
private String writerId;
|
||||
|
||||
private String writerName;
|
||||
|
||||
private String adminYn;
|
||||
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private boolean deletable;
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.repository;
|
||||
|
||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface InquiryCommentRepository extends JpaRepository<InquiryComment, String> {
|
||||
|
||||
List<InquiryComment> findByInquiry_IdAndDelYnOrderByCreatedDateAsc(String inquiryId, String delYn);
|
||||
|
||||
@Query("select c.inquiry.id, count(c) from InquiryComment c"
|
||||
+ " where c.inquiry.id in :inquiryIds and c.delYn = :delYn"
|
||||
+ " group by c.inquiry.id")
|
||||
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
|
||||
@Param("delYn") String delYn);
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.repository;
|
||||
|
||||
import com.eactive.apim.portal.user.entity.UserInfo;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@EMSDataSource
|
||||
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
|
||||
|
||||
/**
|
||||
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
|
||||
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
|
||||
*/
|
||||
@Query(value = "SELECT * FROM TSEAIRM02 t"
|
||||
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
|
||||
nativeQuery = true)
|
||||
List<UserInfo> findByRoleContaining(@Param("role") String role);
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.constant.DjbAdminRole;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import com.eactive.apim.portal.user.entity.UserInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Q&A 등록/댓글 등록 시 portal-admin 역할(TSEAIRM02)을 가진 관리자 전원에게
|
||||
* 알림 메시지를 발행한다. 발송 실패는 트랜잭션 롤백을 유발하지 않는다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CommunityAdminNotifier {
|
||||
|
||||
private final UserInfoRepository userInfoRepository;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
|
||||
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
|
||||
try {
|
||||
List<UserInfo> admins = userInfoRepository.findByRoleContaining(DjbAdminRole.PORTAL_ADMIN);
|
||||
if (admins == null || admins.isEmpty()) {
|
||||
log.warn("portal-admin 역할 관리자가 없습니다 — 알림 미발송 code={}", code.name());
|
||||
return;
|
||||
}
|
||||
for (UserInfo admin : admins) {
|
||||
try {
|
||||
MessageRecipient recipient = toRecipient(admin);
|
||||
messageHandlerService.publishEvent(code, recipient, params);
|
||||
} catch (Exception e) {
|
||||
log.warn("portal-admin 개별 알림 발행 실패 — userid={}, code={}",
|
||||
admin.getUserid(), code.name(), e);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("portal-admin 알림 발행 실패 — code={}", code.name(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private MessageRecipient toRecipient(UserInfo admin) {
|
||||
MessageRecipient r = new MessageRecipient();
|
||||
r.setUsername(admin.getUsername());
|
||||
r.setUserId(admin.getEmad());
|
||||
r.setPhone(admin.getCphnno());
|
||||
r.setMessengerId(admin.getUserid());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.service;
|
||||
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface InquiryCommentFacade {
|
||||
|
||||
List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current);
|
||||
|
||||
InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current);
|
||||
|
||||
void deleteOwnComment(String commentId, PortalAuthenticatedUser current);
|
||||
|
||||
Map<String, Long> countActiveByInquiryIds(Collection<String> inquiryIds);
|
||||
}
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.community.qna.service.InquiryService;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
|
||||
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.user.entity.UserInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
|
||||
private static final String ADMIN_N = "N";
|
||||
private static final String DEL_N = "N";
|
||||
|
||||
private final InquiryService inquiryService;
|
||||
private final InquiryCommentService commentService;
|
||||
private final InquiryCommentRepository commentRepository;
|
||||
private final InquiryCommentPermissionChecker permissionChecker;
|
||||
private final CommunityAdminNotifier adminNotifier;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final UserInfoRepository userInfoRepository;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current) {
|
||||
inquiryService.getAccessibleInquiry(current, inquiryId);
|
||||
List<InquiryComment> comments = commentService.findActiveByInquiryId(inquiryId);
|
||||
Map<String, String> writerNames = resolveWriterNames(comments);
|
||||
return comments.stream()
|
||||
.map(c -> toDto(c, current, writerNames))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current) {
|
||||
Inquiry inquiry = inquiryService.getAccessibleInquiry(current, inquiryId);
|
||||
permissionChecker.assertWritable(inquiry);
|
||||
InquiryComment saved = commentService.create(inquiry, content, ADMIN_N);
|
||||
publishAdminNotification(inquiry, saved, current);
|
||||
Map<String, String> writerNames = new HashMap<>();
|
||||
writerNames.put(current.getId(), current.getUserName());
|
||||
return toDto(saved, current, writerNames);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, Long> countActiveByInquiryIds(Collection<String> inquiryIds) {
|
||||
if (inquiryIds == null || inquiryIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<Object[]> rows = commentRepository.countActiveGroupByInquiry(inquiryIds, DEL_N);
|
||||
Map<String, Long> result = new HashMap<>();
|
||||
for (Object[] row : rows) {
|
||||
String inquiryId = (String) row[0];
|
||||
Long count = ((Number) row[1]).longValue();
|
||||
result.put(inquiryId, count);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteOwnComment(String commentId, PortalAuthenticatedUser current) {
|
||||
InquiryComment comment = commentService.findById(commentId)
|
||||
.orElseThrow(() -> new NotFoundException("댓글을 찾을 수 없습니다. id=" + commentId));
|
||||
inquiryService.getAccessibleInquiry(current, comment.getInquiry().getId());
|
||||
permissionChecker.assertDeletable(comment, current);
|
||||
commentService.softDelete(commentId);
|
||||
}
|
||||
|
||||
private void publishAdminNotification(Inquiry inquiry, InquiryComment comment, PortalAuthenticatedUser current) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("inquiryId", inquiry.getId());
|
||||
params.put("inquirySubject", inquiry.getInquirySubject());
|
||||
params.put("commentContent", comment.getCommentDetail());
|
||||
params.put("writerName", current.getUserName());
|
||||
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
|
||||
}
|
||||
|
||||
private Map<String, String> resolveWriterNames(List<InquiryComment> comments) {
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (InquiryComment c : comments) {
|
||||
String createdBy = c.getCreatedBy();
|
||||
if (createdBy != null && !createdBy.isEmpty()) {
|
||||
ids.add(createdBy);
|
||||
}
|
||||
}
|
||||
Map<String, String> map = new HashMap<>();
|
||||
for (String id : ids) {
|
||||
String name = lookupWriterName(id);
|
||||
if (name != null && !name.isEmpty()) {
|
||||
map.put(id, name);
|
||||
} else {
|
||||
log.warn("댓글 작성자명 조회 실패 — createdBy={}, isUuid={}", id, isUuid(id));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private String lookupWriterName(String createdBy) {
|
||||
if (isUuid(createdBy)) {
|
||||
String name = portalUserRepository.findById(createdBy)
|
||||
.map(PortalUser::getUserName).orElse(null);
|
||||
if (name != null && !name.isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
return userInfoRepository.findById(createdBy)
|
||||
.map(UserInfo::getUsername).orElse(null);
|
||||
}
|
||||
String name = userInfoRepository.findById(createdBy)
|
||||
.map(UserInfo::getUsername).orElse(null);
|
||||
if (name != null && !name.isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
return portalUserRepository.findById(createdBy)
|
||||
.map(PortalUser::getUserName).orElse(null);
|
||||
}
|
||||
|
||||
private boolean isUuid(String value) {
|
||||
if (value == null || value.length() != 36) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
UUID.fromString(value);
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private InquiryCommentDTO toDto(InquiryComment entity, PortalAuthenticatedUser current,
|
||||
Map<String, String> writerNames) {
|
||||
String writerId = entity.getCreatedBy();
|
||||
boolean deletable = writerId != null
|
||||
&& writerId.equals(current.getId())
|
||||
&& entity.getInquiry() != null
|
||||
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
|
||||
String writerName = writerId != null ? writerNames.get(writerId) : null;
|
||||
if (writerName == null || writerName.isEmpty()) {
|
||||
writerName = "(알 수 없음)";
|
||||
}
|
||||
return InquiryCommentDTO.builder()
|
||||
.id(entity.getId())
|
||||
.content(entity.getCommentDetail())
|
||||
.writerId(writerId)
|
||||
.writerName(writerName)
|
||||
.adminYn(entity.getAdminYn())
|
||||
.createdDate(entity.getCreatedDate())
|
||||
.deletable(deletable)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.service;
|
||||
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class InquiryCommentService {
|
||||
|
||||
private static final String DEL_N = "N";
|
||||
|
||||
private final InquiryCommentRepository repository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<InquiryComment> findActiveByInquiryId(String inquiryId) {
|
||||
return repository.findByInquiry_IdAndDelYnOrderByCreatedDateAsc(inquiryId, DEL_N);
|
||||
}
|
||||
|
||||
public InquiryComment create(Inquiry inquiry, String content, String adminYn) {
|
||||
InquiryComment comment = new InquiryComment();
|
||||
comment.setInquiry(inquiry);
|
||||
comment.setCommentDetail(content);
|
||||
comment.setAdminYn(adminYn);
|
||||
comment.setDelYn(DEL_N);
|
||||
return repository.save(comment);
|
||||
}
|
||||
|
||||
public Optional<InquiryComment> findById(String commentId) {
|
||||
return repository.findById(commentId);
|
||||
}
|
||||
|
||||
public void softDelete(String commentId) {
|
||||
InquiryComment comment = repository.findById(commentId)
|
||||
.orElseThrow(() -> new NotFoundException("댓글을 찾을 수 없습니다. id=" + commentId));
|
||||
comment.markDeleted();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.constant;
|
||||
|
||||
public final class DjbAdminRole {
|
||||
|
||||
/** TSEAIRM02.roleidnfiname 컬럼에서 포털 관리자를 식별하는 값. */
|
||||
public static final String PORTAL_ADMIN = "portal-admin";
|
||||
|
||||
private DjbAdminRole() {
|
||||
}
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.constant;
|
||||
|
||||
public final class DjbInquiryStatus {
|
||||
|
||||
public static final String PENDING = "PENDING";
|
||||
public static final String RESPONDED = "RESPONDED";
|
||||
public static final String CLOSED = "CLOSED";
|
||||
|
||||
private DjbInquiryStatus() {
|
||||
}
|
||||
|
||||
public static boolean isClosed(String status) {
|
||||
return CLOSED.equals(status);
|
||||
}
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.exception;
|
||||
|
||||
public class InquiryClosedException extends RuntimeException {
|
||||
|
||||
public InquiryClosedException() {
|
||||
super("종료된 문의에는 댓글을 작성/삭제할 수 없습니다.");
|
||||
}
|
||||
|
||||
public InquiryClosedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.exception;
|
||||
|
||||
public class InquiryCommentNotOwnedException extends RuntimeException {
|
||||
|
||||
public InquiryCommentNotOwnedException() {
|
||||
super("본인이 작성한 댓글만 삭제할 수 있습니다.");
|
||||
}
|
||||
|
||||
public InquiryCommentNotOwnedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.support;
|
||||
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
|
||||
import com.eactive.apim.portal.djb.community.qna.exception.InquiryClosedException;
|
||||
import com.eactive.apim.portal.djb.community.qna.exception.InquiryCommentNotOwnedException;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class InquiryCommentPermissionChecker {
|
||||
|
||||
public boolean canWrite(Inquiry inquiry) {
|
||||
return inquiry != null && !DjbInquiryStatus.isClosed(inquiry.getInquiryStatus());
|
||||
}
|
||||
|
||||
public boolean canDelete(InquiryComment comment, PortalAuthenticatedUser current) {
|
||||
if (comment == null || current == null) {
|
||||
return false;
|
||||
}
|
||||
boolean owner = comment.getCreatedBy() != null
|
||||
&& comment.getCreatedBy().equals(current.getId());
|
||||
boolean notClosed = comment.getInquiry() == null
|
||||
|| !DjbInquiryStatus.isClosed(comment.getInquiry().getInquiryStatus());
|
||||
return owner && notClosed;
|
||||
}
|
||||
|
||||
public void assertWritable(Inquiry inquiry) {
|
||||
if (!canWrite(inquiry)) {
|
||||
throw new InquiryClosedException();
|
||||
}
|
||||
}
|
||||
|
||||
public void assertDeletable(InquiryComment comment, PortalAuthenticatedUser current) {
|
||||
if (comment.getInquiry() != null
|
||||
&& DjbInquiryStatus.isClosed(comment.getInquiry().getInquiryStatus())) {
|
||||
throw new InquiryClosedException();
|
||||
}
|
||||
if (comment.getCreatedBy() == null
|
||||
|| !comment.getCreatedBy().equals(current.getId())) {
|
||||
throw new InquiryCommentNotOwnedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: false
|
||||
enabled: true
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
@@ -39,7 +39,7 @@ gateway:
|
||||
|
||||
|
||||
portal:
|
||||
# auth-virtual-code: 654321
|
||||
auth-virtual-code: 654321
|
||||
test-auth-notice-enabled: true
|
||||
dev:
|
||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: false
|
||||
enabled: true
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
@@ -11,9 +11,6 @@ spring:
|
||||
cachecontrol:
|
||||
max-age: 86400
|
||||
public: true
|
||||
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
|
||||
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
|
||||
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
|
||||
jpa:
|
||||
properties:
|
||||
hibernate:
|
||||
@@ -29,10 +26,4 @@ spring:
|
||||
cache: false
|
||||
|
||||
portal:
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
resource-caching:
|
||||
enabled: true
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: false
|
||||
enabled: true
|
||||
web:
|
||||
resources:
|
||||
cache:
|
||||
|
||||
@@ -2,9 +2,7 @@ server:
|
||||
servlet:
|
||||
context-path: /
|
||||
session:
|
||||
timeout: 10m
|
||||
cookie:
|
||||
name: PORTAL_JSESSIONID
|
||||
timeout: 15m
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
port: '39130'
|
||||
@@ -35,17 +33,12 @@ spring:
|
||||
max-age: 0
|
||||
must-revalidate: true
|
||||
no-cache: true
|
||||
# 정적자원 콘텐츠 해시 버전닝(/css/main.css -> /css/main-<hash>.css)은
|
||||
# @EnableWebMvc 로 WebMvcAutoConfiguration 이 비활성화되어 spring.web.resources.chain
|
||||
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
|
||||
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
|
||||
|
||||
jta:
|
||||
enabled: false
|
||||
enabled: true
|
||||
atomikos:
|
||||
properties:
|
||||
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
|
||||
log-base-dir: /dev/null
|
||||
log-base-dir: /logs/atomikos
|
||||
log-base-name: adp_tx
|
||||
|
||||
thymeleaf:
|
||||
@@ -56,19 +49,6 @@ spring:
|
||||
cache: false
|
||||
main:
|
||||
allow-bean-definition-overriding: true
|
||||
|
||||
# 정적자원 해시 버전닝 토글 (prod 제외 전 프로파일에 적용).
|
||||
# false 로 변경 시 /css/main.css 가 해시 없이 그대로 서빙되어 sourceMap 디버깅 가능.
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: false
|
||||
# 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
|
||||
# false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
resource-caching:
|
||||
enabled: false
|
||||
|
||||
security:
|
||||
basic:
|
||||
enabled: false
|
||||
@@ -255,18 +235,12 @@ page:
|
||||
corp:
|
||||
name: "법인회원가입"
|
||||
path: "/signup/portalOrg"
|
||||
verification_email:
|
||||
name: "이메일 인증"
|
||||
path: "/signup/verification-email"
|
||||
decision:
|
||||
name: "법인회원 초대"
|
||||
path: "/signup/decision_process"
|
||||
agreements:
|
||||
name: 약관
|
||||
path: "#"
|
||||
children:
|
||||
terms:
|
||||
name: "이용약관"
|
||||
name: "이용약관 및 개인정보처리방침"
|
||||
path: "/agreements/terms"
|
||||
about:
|
||||
name: 안내
|
||||
@@ -336,9 +310,6 @@ page:
|
||||
mypage:
|
||||
name: "내 정보 관리"
|
||||
path: "/mypage"
|
||||
verification_email:
|
||||
name: "이메일 인증"
|
||||
path: "/mypage/verification-email"
|
||||
user_list:
|
||||
name: "이용자 관리"
|
||||
path: "/users"
|
||||
|
||||
@@ -5,19 +5,12 @@
|
||||
|
||||
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
|
||||
|
||||
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
|
||||
<!-- <springProfile name="gf63">-->
|
||||
<!-- <property name="LOG_PATH" value="d:/kjb-logs/${inst.Name:-devSvr00}"/>-->
|
||||
<!-- </springProfile>-->
|
||||
|
||||
<!-- 콘솔 로그 JVM 파라미터 제어 -->
|
||||
<!-- 사용: -DCONSOLE_LOG_ENABLED=true -DCONSOLE_LOG_LEVEL=DEBUG -->
|
||||
<!-- 기본값: 비활성(콘솔 OFF), 활성 시 레벨 INFO -->
|
||||
<property name="CONSOLE_LOG_ENABLED" value="${CONSOLE_LOG_ENABLED:-false}"/>
|
||||
<property name="CONSOLE_LOG_LEVEL" value="${CONSOLE_LOG_LEVEL:-INFO}"/>
|
||||
<!-- enabled=true → 지정 레벨, false → OFF (대소문자 모두 대응) -->
|
||||
<property name="__consoleLevel.true" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.TRUE" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.false" value="OFF"/>
|
||||
<property name="__consoleLevel.FALSE" value="OFF"/>
|
||||
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
|
||||
<!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />-->
|
||||
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
|
||||
|
||||
|
||||
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
@@ -44,6 +37,18 @@
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="NEED_FIX" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/need-fix.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/backup/need-fix.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<maxFileSize>500MB</maxFileSize>
|
||||
<maxHistory>90</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="HTTP_SESSION" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/http-session.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
@@ -57,13 +62,14 @@
|
||||
</appender>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
||||
</filter>
|
||||
<encoder><Pattern>${CONSOLE_PATTERN}</Pattern></encoder>
|
||||
</appender>
|
||||
|
||||
|
||||
<logger name="eapim.portal.needfix" level="INFO" additivity="false">
|
||||
<appender-ref ref="NEED_FIX" />
|
||||
</logger>
|
||||
|
||||
<logger name="eapim.portal.session" level="INFO" additivity="false">
|
||||
<appender-ref ref="HTTP_SESSION" />
|
||||
</logger>
|
||||
@@ -71,7 +77,6 @@
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
|
||||
<springProfile name="dev">
|
||||
@@ -80,4 +85,62 @@
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<springProfile name="stage">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
|
||||
<springProfile name="stage,dev">
|
||||
|
||||
</springProfile>
|
||||
|
||||
|
||||
<springProfile name="gf63">
|
||||
<logger name="sun.rmi" level="INFO"/>
|
||||
|
||||
<logger name="javax" level="INFO"/>
|
||||
|
||||
<logger name="jdk.event.security" level="INFO"/>
|
||||
|
||||
<logger name="com.zaxxer.hikari" level="INFO"/>
|
||||
<logger name="com.atomikos" level="INFO"/>
|
||||
|
||||
<logger name="org.apache" level="INFO"/>
|
||||
<logger name="org.codehaus.groovy.vmplugin.VMPluginFactory" level="INFO"/>
|
||||
<!-- <logger name="org.hibernate" level="INFO" additivity="false"/>-->
|
||||
<logger name="org.thymeleaf.TemplateEngine" level="INFO"/>
|
||||
<!-- <logger name="org.springframework" level="INFO" />-->
|
||||
<logger name="_org.springframework.web" level="INFO" />
|
||||
|
||||
|
||||
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
|
||||
<appender-ref ref="HIBERNATE_APPENDER" />
|
||||
</logger>
|
||||
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
|
||||
<appender-ref ref="HIBERNATE_APPENDER" />
|
||||
</logger>
|
||||
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" additivity="false">
|
||||
<appender-ref ref="HIBERNATE_APPENDER" />
|
||||
</logger>
|
||||
|
||||
<logger name="com.eactive.eapim" level="DEBUG"/>
|
||||
<logger name="kjb.safedb" level="DEBUG" additivity="false">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</logger>
|
||||
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
|
||||
<!-- <root level="DEBUG">-->
|
||||
<!-- <appender-ref ref="ROLLING"/>-->
|
||||
<!-- <appender-ref ref="CONSOLE"/>-->
|
||||
<!-- </root>-->
|
||||
</springProfile>
|
||||
</configuration>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user