Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08bd705dec | |||
| 99f765507b | |||
| 7d6aabe122 | |||
| f08da86eb3 | |||
| 2a2505d5f7 | |||
| cad574324e | |||
| 4b4ef4196e | |||
| 5402a5354a | |||
| c5b23c3873 | |||
| 83d267faed | |||
| b02e598d62 | |||
| 4f9fbefbcc | |||
| c403118289 |
@@ -1,220 +1,159 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent { label 'djb-vm' }
|
agent none
|
||||||
|
|
||||||
options {
|
options {
|
||||||
timestamps()
|
timestamps()
|
||||||
disableConcurrentBuilds()
|
disableConcurrentBuilds()
|
||||||
|
skipDefaultCheckout() // stage별 agent의 자동 SCM checkout 방지 (Deploy 노드는 git 접근 불필요, unstash로만 WAR 수신)
|
||||||
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||||
}
|
}
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
JAVA_HOME = '/apps/opts/jdk8'
|
|
||||||
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
|
|
||||||
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
|
||||||
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
|
||||||
NODE_HOME = '/apps/opts/node-v24'
|
|
||||||
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
|
|
||||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||||
CATALINA_BASE = '/prod/eapim/devportal'
|
|
||||||
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'
|
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
stage('Checkout') {
|
stage('Build (djb-vm)') {
|
||||||
steps {
|
agent { label 'djb-vm' }
|
||||||
checkout scm
|
environment {
|
||||||
|
JAVA_HOME = '/apps/opts/jdk8'
|
||||||
|
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||||
|
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||||
|
NODE_HOME = '/apps/opts/node-v24'
|
||||||
|
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
|
||||||
}
|
}
|
||||||
}
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
stage('Checkout dependencies') {
|
steps { checkout scm }
|
||||||
steps {
|
}
|
||||||
sh '''
|
stage('Checkout dependencies') {
|
||||||
set -eu
|
steps {
|
||||||
cd "$WORKSPACE/.."
|
sh '''
|
||||||
|
set -eu
|
||||||
if [ ! -d elink-portal-common/.git ]; then
|
cd "$WORKSPACE/.."
|
||||||
rm -rf elink-portal-common
|
if [ ! -d elink-portal-common/.git ]; then
|
||||||
git clone --depth=1 --branch master \
|
rm -rf elink-portal-common
|
||||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
|
git clone --depth=1 --branch master \
|
||||||
elink-portal-common
|
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git elink-portal-common
|
||||||
else
|
else
|
||||||
git -C elink-portal-common fetch --depth=1 origin master
|
git -C elink-portal-common fetch --depth=1 origin master
|
||||||
git -C elink-portal-common reset --hard origin/master
|
git -C elink-portal-common reset --hard origin/master
|
||||||
git -C elink-portal-common clean -fdx
|
git -C elink-portal-common clean -fdx
|
||||||
fi
|
fi
|
||||||
|
mkdir -p eapim-online
|
||||||
mkdir -p eapim-online
|
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
|
||||||
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
|
rm -rf eapim-online/elink-online-core-jpa
|
||||||
rm -rf eapim-online/elink-online-core-jpa
|
git clone --depth=1 --branch master \
|
||||||
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
|
||||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
|
else
|
||||||
eapim-online/elink-online-core-jpa
|
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
||||||
else
|
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
||||||
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
git -C eapim-online/elink-online-core-jpa clean -fdx
|
||||||
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
fi
|
||||||
git -C eapim-online/elink-online-core-jpa clean -fdx
|
'''
|
||||||
fi
|
}
|
||||||
'''
|
}
|
||||||
}
|
stage('Verify toolchain') {
|
||||||
}
|
steps { sh 'set -eu; java -version; gradle --version' }
|
||||||
|
}
|
||||||
stage('Verify toolchain') {
|
stage('Test') {
|
||||||
steps {
|
steps { sh 'gradle clean test --no-daemon -Pprofile=weblogic' }
|
||||||
sh '''
|
post {
|
||||||
set -eu
|
always {
|
||||||
java -version
|
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||||
gradle --version
|
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
|
||||||
'''
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stage('Build WAR') {
|
||||||
stage('Test') {
|
steps { sh 'gradle build -x test --no-daemon -Pprofile=weblogic' }
|
||||||
steps {
|
post {
|
||||||
sh 'gradle clean test --no-daemon'
|
success {
|
||||||
}
|
sh '''
|
||||||
post {
|
set -eu
|
||||||
always {
|
cd build/libs
|
||||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
||||||
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
|
'''
|
||||||
|
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
|
||||||
|
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Build WAR') {
|
stage('Deploy (weblogic)') {
|
||||||
steps {
|
agent { label 'weblogic' }
|
||||||
sh 'gradle build -x test --no-daemon'
|
environment {
|
||||||
|
JENKINS_NODE_COOKIE = 'dontKillMe' // background weblogic를 빌드 종료 시 죽이지 않게
|
||||||
|
WL_HOME = '/app/eapim/devportal'
|
||||||
|
WL_DEPLOY_DIR = '/app/eapim/devportal'
|
||||||
|
WL_WAR_NAME = 'eapim-portal.war'
|
||||||
|
WL_HTTP_PORT = '39130'
|
||||||
|
WL_NOHUP = '/logs/weblogic/domains/eapimDomain/nohup.devSvr11.out'
|
||||||
}
|
}
|
||||||
post {
|
stages {
|
||||||
success {
|
stage('Stop WebLogic') {
|
||||||
sh '''
|
steps {
|
||||||
set -eu
|
sh '"$WL_HOME/stopDev11.sh"' // 동기: 완전 종료까지 블록, 미기동 시에도 안전
|
||||||
cd build/libs
|
}
|
||||||
for f in eapim-portal.war eapim-portal-boot.war; do
|
|
||||||
sha1sum "$f" > "$f.sha1"
|
|
||||||
sha256sum "$f" > "$f.sha256"
|
|
||||||
md5sum "$f" > "$f.md5"
|
|
||||||
done
|
|
||||||
'''
|
|
||||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
|
|
||||||
}
|
}
|
||||||
}
|
stage('Deploy WAR') {
|
||||||
}
|
steps {
|
||||||
|
unstash 'war'
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
cp build/libs/eapim-portal.war "$WL_DEPLOY_DIR/$WL_WAR_NAME"
|
||||||
|
ls -la "$WL_DEPLOY_DIR"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Start WebLogic and readiness') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
"$WL_HOME/startDev11.sh" # nohup & 로 백그라운드 기동, 즉시 리턴
|
||||||
|
|
||||||
stage('Stop Tomcat') {
|
DEADLINE=$(($(date +%s) + 300))
|
||||||
steps {
|
STATUS=000
|
||||||
sh '''
|
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||||
set +e
|
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||||
systemctl --user stop eapim-portal 2>/dev/null
|
"http://localhost:$WL_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
|
||||||
|
[ "$STATUS" = "200" ] && { echo "Readiness OK (/health/ready)"; break; }
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
if [ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null; then
|
if [ "$STATUS" != "200" ]; then
|
||||||
JAVA_HOME="$JAVA_HOME_TOMCAT" "$CATALINA_HOME/bin/shutdown.sh" 30 -force || true
|
echo "Readiness failed within 300s, last HTTP=$STATUS"
|
||||||
for i in $(seq 1 30); do
|
[ -f "$WL_NOHUP" ] && tail -120 "$WL_NOHUP"
|
||||||
[ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null || break
|
exit 1
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
rm -f "$CATALINA_PID"
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Deploy ROOT.war') {
|
|
||||||
steps {
|
|
||||||
sh '''
|
|
||||||
set -eu
|
|
||||||
DEPLOY_DIR="$CATALINA_BASE/webapps"
|
|
||||||
rm -rf "$DEPLOY_DIR/ROOT" "$DEPLOY_DIR/ROOT.war"
|
|
||||||
cp build/libs/eapim-portal.war "$DEPLOY_DIR/ROOT.war"
|
|
||||||
ls -la "$DEPLOY_DIR"
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Start Tomcat and readiness') {
|
|
||||||
steps {
|
|
||||||
sh '''
|
|
||||||
set -eu
|
|
||||||
LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
|
|
||||||
BEFORE=0
|
|
||||||
[ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
|
|
||||||
|
|
||||||
systemctl --user start eapim-portal
|
|
||||||
|
|
||||||
DEADLINE=$(($(date +%s) + 120))
|
|
||||||
LIVE_OK=0
|
|
||||||
STATUS=000
|
|
||||||
|
|
||||||
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
|
||||||
if [ "$LIVE_OK" = "0" ]; then
|
|
||||||
LIVE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
|
|
||||||
"http://localhost:$DEPLOY_HTTP_PORT/health" 2>/dev/null || echo "000")
|
|
||||||
if [ "$LIVE" = "200" ]; then
|
|
||||||
echo "Liveness OK (/health)"
|
|
||||||
LIVE_OK=1
|
|
||||||
fi
|
fi
|
||||||
fi
|
'''
|
||||||
|
}
|
||||||
if [ "$LIVE_OK" = "1" ]; then
|
}
|
||||||
STATUS=$(curl -sS -o /tmp/eapim-ready-body.json -w '%{http_code}' --max-time 3 \
|
|
||||||
"http://localhost:$DEPLOY_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
|
|
||||||
if [ "$STATUS" = "200" ]; then
|
|
||||||
echo "Readiness OK (/health/ready)"
|
|
||||||
cat /tmp/eapim-ready-body.json
|
|
||||||
echo
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | grep -qE "Application listener .* Exception|SEVERE.*startup.Catalina"; then
|
|
||||||
echo "Startup error detected in log"
|
|
||||||
tail -c +$((BEFORE+1)) "$LOG" | tail -80
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$STATUS" != "200" ]; then
|
|
||||||
echo "Readiness probe failed within 120s, last HTTP status: $STATUS"
|
|
||||||
[ -f /tmp/eapim-ready-body.json ] && cat /tmp/eapim-ready-body.json && echo
|
|
||||||
[ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | tail -100
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--- key boot log lines ---"
|
|
||||||
tail -c +$((BEFORE+1)) "$LOG" | grep -E "Server startup|Deployment of web" | head -10 || true
|
|
||||||
'''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
success {
|
success {
|
||||||
sh '''
|
node('weblogic') {
|
||||||
set +e
|
sh '''
|
||||||
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}]}]}' \
|
set +e
|
||||||
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
|
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
||||||
curl -sS --fail --max-time 5 \
|
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
||||||
-H 'Content-Type: application/json' \
|
'''
|
||||||
-X POST \
|
}
|
||||||
--data "$payload" \
|
|
||||||
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for SUCCESS"
|
|
||||||
'''
|
|
||||||
}
|
}
|
||||||
failure {
|
failure {
|
||||||
sh '''
|
node('weblogic') {
|
||||||
set +e
|
sh '''
|
||||||
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}]}]}' \
|
set +e
|
||||||
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
|
payload=$(printf '{"text":"[%s #%s](%s) FAILURE"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
||||||
curl -sS --fail --max-time 5 \
|
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
||||||
-H 'Content-Type: application/json' \
|
'''
|
||||||
-X POST \
|
}
|
||||||
--data "$payload" \
|
|
||||||
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for FAILURE"
|
|
||||||
'''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
|||||||
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
|
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
|
||||||
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
||||||
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
||||||
|
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
|
||||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
||||||
@@ -21,6 +22,7 @@ import java.util.Map;
|
|||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -44,6 +46,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import org.springframework.web.servlet.ModelAndView;
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/myapikey")
|
@RequestMapping("/myapikey")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -77,6 +80,7 @@ public class MyAppController {
|
|||||||
private final ApiService apiService;
|
private final ApiService apiService;
|
||||||
private final ApiServiceHelper apiServiceHelper;
|
private final ApiServiceHelper apiServiceHelper;
|
||||||
private final FileTypeDetector fileTypeDetector;
|
private final FileTypeDetector fileTypeDetector;
|
||||||
|
private final AdminGatewayClient adminGatewayClient;
|
||||||
|
|
||||||
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
@@ -168,7 +172,13 @@ public class MyAppController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Client Secret은 초기 HTML에 담지 않는다. (비번 확인 후 서버가 1회만 반환)
|
||||||
|
boolean secretAvailable = StringUtils.isNotBlank(apiKey.getClientsecret());
|
||||||
|
apiKey.setClientsecret(null);
|
||||||
|
|
||||||
model.addAttribute("apiKey", apiKey);
|
model.addAttribute("apiKey", apiKey);
|
||||||
|
model.addAttribute("secretAvailable", secretAvailable);
|
||||||
|
model.addAttribute("authType", "OAuth2");
|
||||||
|
|
||||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||||
}
|
}
|
||||||
@@ -228,28 +238,45 @@ public class MyAppController {
|
|||||||
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
|
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
|
||||||
Map<String, Object> result = new java.util.HashMap<>();
|
Map<String, Object> result = new java.util.HashMap<>();
|
||||||
|
|
||||||
try {
|
String clientId = requestData.get("clientId");
|
||||||
String clientId = requestData.get("clientId");
|
if (clientId == null || clientId.trim().isEmpty()) {
|
||||||
String type = requestData.get("type");
|
|
||||||
|
|
||||||
if (clientId == null || clientId.trim().isEmpty()) {
|
|
||||||
result.put("success", false);
|
|
||||||
result.put("msg", "클라이언트 ID가 필요합니다.");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
|
||||||
|
|
||||||
// API Key 즉시 삭제 (승인 프로세스 없이)
|
|
||||||
appServiceFacade.deleteApp(user.getPortalOrg().getId(), clientId);
|
|
||||||
|
|
||||||
result.put("success", true);
|
|
||||||
result.put("msg", "API Key가 삭제되었습니다.");
|
|
||||||
} catch (Exception e) {
|
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
result.put("msg", "클라이언트 ID가 필요합니다.");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
String orgId = user.getPortalOrg().getId();
|
||||||
|
|
||||||
|
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
|
||||||
|
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
|
||||||
|
try {
|
||||||
|
adminGatewayClient.blockClient(clientId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. GW 차단 성공 시에만 포털 credential 삭제
|
||||||
|
try {
|
||||||
|
appServiceFacade.deleteApp(orgId, clientId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("msg", "API Key가 삭제되었습니다.");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +309,57 @@ public class MyAppController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
|
||||||
|
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
|
||||||
|
*
|
||||||
|
* @param requestData clientId, password 포함
|
||||||
|
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
|
||||||
|
*/
|
||||||
|
@PostMapping("/credential/reveal-secret")
|
||||||
|
@Secured("ROLE_APP")
|
||||||
|
@ResponseBody
|
||||||
|
public Map<String, Object> revealClientSecret(@RequestBody Map<String, String> requestData) {
|
||||||
|
Map<String, Object> result = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
String clientId = requestData.get("clientId");
|
||||||
|
String password = requestData.get("password");
|
||||||
|
|
||||||
|
if (clientId == null || clientId.trim().isEmpty()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "클라이언트 ID가 필요합니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
|
||||||
|
// 1. 본인 확인 (비밀번호)
|
||||||
|
if (!appServiceFacade.verifyUserPassword(user, password)) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
|
||||||
|
try {
|
||||||
|
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
|
||||||
|
if (secret == null) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("alreadyRevealed", true);
|
||||||
|
result.put("message", "이미 1회 노출되어 삭제된 인증정보입니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("secret", secret);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Client Secret 노출 처리 실패 - clientId={}", clientId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "인증정보 조회 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key 요청 이력을 페이지 형태로 조회합니다.
|
* API Key 요청 이력을 페이지 형태로 조회합니다.
|
||||||
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
|
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.eactive.apim.portal.apps.app.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||||
|
*
|
||||||
|
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||||
|
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminGatewayClient {
|
||||||
|
|
||||||
|
private static final String PROP_GROUP = "Portal";
|
||||||
|
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||||
|
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||||
|
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||||
|
*
|
||||||
|
* @param clientId 차단할 클라이언트 ID
|
||||||
|
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||||
|
*/
|
||||||
|
public void blockClient(String clientId) {
|
||||||
|
String baseUrl = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
||||||
|
|
||||||
|
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
|
||||||
|
|
||||||
|
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
||||||
|
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||||
|
|
||||||
|
Map<?, ?> body = response.getBody();
|
||||||
|
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||||
|
if (!success) {
|
||||||
|
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||||
|
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -293,6 +293,29 @@ public class AppServiceFacade {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client Secret을 1회 조회하고 그 즉시 DB(PTL_CREDENTIAL)에서 물리 삭제합니다.
|
||||||
|
* 보안 정책상 비밀정보는 최초 1회만 제공되며, 조회 후에는 복구할 수 없습니다.
|
||||||
|
*
|
||||||
|
* @param orgId 조직 ID (소유권 확인용)
|
||||||
|
* @param clientId 대상 클라이언트 ID
|
||||||
|
* @return 삭제 전 Client Secret 값. 이미 노출되어 비어있으면 {@code null}
|
||||||
|
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
public String revealAndDeleteClientSecret(String orgId, String clientId) {
|
||||||
|
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
|
||||||
|
|
||||||
|
String secret = credential.getClientsecret();
|
||||||
|
if (StringUtils.isBlank(secret)) {
|
||||||
|
return null; // 이미 1회 노출되어 삭제됨
|
||||||
|
}
|
||||||
|
|
||||||
|
credential.setClientsecret(null); // 물리 삭제 (1회 제공)
|
||||||
|
credentialRepository.save(credential);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key(Credential)를 즉시 삭제합니다.
|
* API Key(Credential)를 즉시 삭제합니다.
|
||||||
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.constant;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Application constants.
|
|
||||||
*/
|
|
||||||
public final class Constants {
|
|
||||||
|
|
||||||
public static final String SYSTEM_ACCOUNT = "system";
|
|
||||||
|
|
||||||
public static final String APIM_FOR_OBP_KEY = "x-obp-trust-system";
|
|
||||||
|
|
||||||
public static final String APIM_CODE_FOR_OBP_KEY = "x-obp-partnercode";
|
|
||||||
|
|
||||||
public static final String SMS_URL = "/api/messaging/sms";
|
|
||||||
|
|
||||||
public static final String COMMISSION_URL = "/api/billing/billing/findBillingForCondition";
|
|
||||||
|
|
||||||
public static final String COMMISSION_PRINT_URL = "/api/billing/billing/findBillingForCondition/print";
|
|
||||||
|
|
||||||
public static final String OBP_ORGANIZATION_URL = "/api/customer/findCustomerByBusinessManRegistrationNo/";
|
|
||||||
|
|
||||||
public static String LANGUAGE = "ko";
|
|
||||||
|
|
||||||
private Constants() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.controller;
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.commission.constant.Constants;
|
|
||||||
import com.eactive.apim.portal.apps.commission.dto.CommissionDTO;
|
|
||||||
import com.eactive.apim.portal.apps.commission.dto.CommissionSearch;
|
|
||||||
import com.eactive.apim.portal.apps.commission.exception.ErrorUtil;
|
|
||||||
import com.eactive.apim.portal.apps.commission.service.ExternalService;
|
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.commons.collections.MapUtils;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.access.annotation.Secured;
|
|
||||||
import org.springframework.stereotype.Controller;
|
|
||||||
import org.springframework.ui.Model;
|
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
|
|
||||||
|
|
||||||
@Controller
|
|
||||||
@RequestMapping(value = "/commission")
|
|
||||||
@Secured("ROLE_APP")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CommissionController {
|
|
||||||
|
|
||||||
private final Logger log = LoggerFactory.getLogger(CommissionController.class);
|
|
||||||
|
|
||||||
private final ExternalService externalService;
|
|
||||||
private final PortalPropertyService portalPropertyService;
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper()
|
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
|
||||||
|
|
||||||
@GetMapping("/manage")
|
|
||||||
public String commissionManagePage(Model model) {
|
|
||||||
// 기본 정보 설정
|
|
||||||
model.addAttribute("organization", SecurityUtil.getUserOrg());
|
|
||||||
model.addAttribute("noOrgCode", StringUtils.isEmpty(SecurityUtil.getUserOrg().getOrgCode()));
|
|
||||||
return "apps/commission/commissionManage";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/print/{year}/{month}")
|
|
||||||
public String commissionPrintPage(
|
|
||||||
@PathVariable("year") String year,
|
|
||||||
@PathVariable("month") String month,
|
|
||||||
Model model) {
|
|
||||||
|
|
||||||
CommissionSearch search = new CommissionSearch();
|
|
||||||
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
|
|
||||||
search.setYear(year);
|
|
||||||
search.setMonth(month);
|
|
||||||
|
|
||||||
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
|
|
||||||
|
|
||||||
if (result.hasError()) {
|
|
||||||
model.addAttribute("error", result.getError());
|
|
||||||
return "apps/commission/commissionPrint";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model에 데이터 바인딩
|
|
||||||
model.addAttribute("commission", result.getData());
|
|
||||||
model.addAttribute("organization", SecurityUtil.getUserOrg().getOrgName());
|
|
||||||
model.addAttribute("year", year);
|
|
||||||
model.addAttribute("month", month);
|
|
||||||
model.addAttribute("toDay", java.time.LocalDate.now().toString());
|
|
||||||
|
|
||||||
return "apps/commission/commissionPrint";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== API Endpoints ====================
|
|
||||||
|
|
||||||
@PostMapping("/print/check")
|
|
||||||
public ResponseEntity<?> checkPrintData(@RequestBody CommissionSearch search) {
|
|
||||||
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
|
|
||||||
|
|
||||||
if (StringUtils.isEmpty(search.getPartnerCode())) {
|
|
||||||
return ErrorUtil.create("fail.commission.needPartnerCode");
|
|
||||||
}
|
|
||||||
|
|
||||||
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
|
|
||||||
|
|
||||||
if (result.hasError()) {
|
|
||||||
return ErrorUtil.create("fail.commission.print", result.getError());
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok().body(result.getData());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping()
|
|
||||||
public ResponseEntity<?> list(@RequestBody CommissionSearch search) {
|
|
||||||
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
|
|
||||||
|
|
||||||
if (StringUtils.isEmpty(search.getPartnerCode())) {
|
|
||||||
return ErrorUtil.create("fail.commission.needPartnerCode");
|
|
||||||
}
|
|
||||||
|
|
||||||
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_URL);
|
|
||||||
|
|
||||||
if (result.hasError()) {
|
|
||||||
return ErrorUtil.create("fail.commission.list", result.getError());
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok().body(result.getData());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Private Methods ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API를 호출하여 수수료 데이터를 조회합니다.
|
|
||||||
*
|
|
||||||
* @param search 검색 조건
|
|
||||||
* @param apiPath API 경로 (Constants.COMMISSION_URL 또는 Constants.COMMISSION_PRINT_URL)
|
|
||||||
* @return 조회 결과
|
|
||||||
*/
|
|
||||||
private CommissionResult fetchCommissionData(CommissionSearch search, String apiPath) {
|
|
||||||
if (StringUtils.isEmpty(search.getPartnerCode())) {
|
|
||||||
return CommissionResult.error("파트너 코드가 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
String obmUrl = portalPropertyService.getPortalPropertiesAsMap("Portal").getOrDefault("obp.obm.url", "");
|
|
||||||
String url = obmUrl + apiPath;
|
|
||||||
|
|
||||||
// 첫 번째 API 호출
|
|
||||||
String result = externalService.getResponseHttpPost(url, toJson(search));
|
|
||||||
if (StringUtils.isEmpty(result)) {
|
|
||||||
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, Object> returnObj = objectMapper.readValue(result, HashMap.class);
|
|
||||||
|
|
||||||
// 에러 응답 체크
|
|
||||||
String errorMessage = extractErrorMessage(returnObj);
|
|
||||||
if (errorMessage != null) {
|
|
||||||
return CommissionResult.error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Together 관련 추가 처리 (000002-01)
|
|
||||||
if (StringUtils.pathEquals(search.getPartnerCode(), "000002-01")) {
|
|
||||||
log.debug("Found Together : " + search.getPartnerCode());
|
|
||||||
|
|
||||||
CommissionSearch togetherSearch = new CommissionSearch();
|
|
||||||
togetherSearch.setPartnerCode("000002-02");
|
|
||||||
togetherSearch.setYear(search.getYear());
|
|
||||||
togetherSearch.setMonth(search.getMonth());
|
|
||||||
|
|
||||||
String togetherResult = externalService.getResponseHttpPost(url, toJson(togetherSearch));
|
|
||||||
if (StringUtils.isEmpty(togetherResult)) {
|
|
||||||
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, Object> togetherReturnObj = objectMapper.readValue(togetherResult, HashMap.class);
|
|
||||||
log.debug("togetherLendingReturnObj : " + togetherReturnObj.toString());
|
|
||||||
|
|
||||||
String togetherErrorMessage = extractErrorMessage(togetherReturnObj);
|
|
||||||
if (togetherErrorMessage != null) {
|
|
||||||
return CommissionResult.error(togetherErrorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, Object> mergeMap = mergeCommissionData(returnObj, togetherReturnObj);
|
|
||||||
result = objectMapper.writeValueAsString(mergeMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
CommissionDTO commissionDTO = objectMapper.readValue(result, CommissionDTO.class);
|
|
||||||
return CommissionResult.success(commissionDTO);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn(e.getMessage(), e);
|
|
||||||
return CommissionResult.error("수수료 조회에 실패하였습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 응답에서 에러 메시지를 추출합니다.
|
|
||||||
*/
|
|
||||||
private String extractErrorMessage(HashMap<String, Object> response) {
|
|
||||||
if (!StringUtils.isEmpty(response.get("error"))) {
|
|
||||||
HashMap<String, Object> error = (HashMap) response.get("error");
|
|
||||||
return error.get("message").toString();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toJson(CommissionSearch search) {
|
|
||||||
try {
|
|
||||||
Map<String, String> jsonMap = new HashMap<>();
|
|
||||||
jsonMap.put("partnerCode", search.getPartnerCode());
|
|
||||||
// 월을 두 자리로 패딩 (예: 1 -> 01, 12 -> 12)
|
|
||||||
String paddedMonth = String.format("%02d", Integer.parseInt(search.getMonth()));
|
|
||||||
jsonMap.put("targetOnMonth", search.getYear() + paddedMonth);
|
|
||||||
|
|
||||||
return objectMapper.writeValueAsString(jsonMap);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to serialize CommissionSearch to JSON", e);
|
|
||||||
return "{}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely parse double value from HashMap
|
|
||||||
* Handles both numeric types and string representations
|
|
||||||
*/
|
|
||||||
private double safeGetDouble(HashMap<String, Object> map, String key) {
|
|
||||||
Object value = map.get(key);
|
|
||||||
if (value == null) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (value instanceof Number) {
|
|
||||||
return ((Number) value).doubleValue();
|
|
||||||
} else if (value instanceof String) {
|
|
||||||
return Double.parseDouble((String) value);
|
|
||||||
}
|
|
||||||
return 0.0;
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
log.warn("Failed to parse double value for key: {} with value: {}", key, value);
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public HashMap<String, Object> mergeCommissionData(HashMap<String, Object> o1, HashMap<String, Object> o2) {
|
|
||||||
|
|
||||||
HashMap<String, Object> mergeMap = new HashMap<String, Object>();
|
|
||||||
|
|
||||||
String o1Status = MapUtils.getString(o1, "status");
|
|
||||||
String o2Status = MapUtils.getString(o2, "status");
|
|
||||||
String resultStatus = null;
|
|
||||||
if (Integer.parseInt(o1Status) <= Integer.parseInt(o2Status)) {
|
|
||||||
resultStatus = o1Status;
|
|
||||||
} else {
|
|
||||||
resultStatus = o2Status;
|
|
||||||
}
|
|
||||||
double apiTotalValueSum = safeGetDouble(o1, "apiTotalValueSum") + safeGetDouble(o2, "apiTotalValueSum");
|
|
||||||
double apiSum = safeGetDouble(o1, "apiSum") + safeGetDouble(o2, "apiSum");
|
|
||||||
double apiTotalValueSum2 = safeGetDouble(o1, "apiTotalValueSum2") + safeGetDouble(o2, "apiTotalValueSum2");
|
|
||||||
double apiSum2 = safeGetDouble(o1, "apiSum2") + safeGetDouble(o2, "apiSum2");
|
|
||||||
double productTotalValueCountSum = safeGetDouble(o1, "productTotalValueCountSum") + safeGetDouble(o2, "productTotalValueCountSum");
|
|
||||||
double productTotalValueAmountSum = safeGetDouble(o1, "productTotalValueAmountSum") + safeGetDouble(o2, "productTotalValueAmountSum");
|
|
||||||
double productSum = safeGetDouble(o1, "productSum") + safeGetDouble(o2, "productSum");
|
|
||||||
double productTotalValueCountSum2 = safeGetDouble(o1, "productTotalValueCountSum2") + safeGetDouble(o2, "productTotalValueCountSum2");
|
|
||||||
double productTotalValueAmountSum2 = safeGetDouble(o1, "productTotalValueAmountSum2") + safeGetDouble(o2, "productTotalValueAmountSum2");
|
|
||||||
double productSum2 = safeGetDouble(o1, "productSum2") + safeGetDouble(o2, "productSum2");
|
|
||||||
double totalValueCountSum = safeGetDouble(o1, "totalValueCountSum") + safeGetDouble(o2, "totalValueCountSum");
|
|
||||||
double totalValueCountSum2 = safeGetDouble(o1, "totalValueCountSum2") + safeGetDouble(o2, "totalValueCountSum2");
|
|
||||||
double totalValueAmountSum = safeGetDouble(o1, "totalValueAmountSum") + safeGetDouble(o2, "totalValueAmountSum");
|
|
||||||
double totalValueAmountSum2 = safeGetDouble(o1, "totalValueAmountSum2") + safeGetDouble(o2, "totalValueAmountSum2");
|
|
||||||
double sum = safeGetDouble(o1, "sum") + safeGetDouble(o2, "sum");
|
|
||||||
double sum2 = safeGetDouble(o1, "sum2") + safeGetDouble(o2, "sum2");
|
|
||||||
|
|
||||||
List<Map> o1List = (ArrayList) o1.get("list");
|
|
||||||
List<Map> o2pList = (ArrayList) o2.get("list");
|
|
||||||
o1List.addAll(o2pList);
|
|
||||||
|
|
||||||
mergeMap.put("status", resultStatus);
|
|
||||||
mergeMap.put("apiTotalValueSum", String.format("%.2f", apiTotalValueSum));
|
|
||||||
mergeMap.put("apiSum", String.format("%.2f", apiSum));
|
|
||||||
mergeMap.put("apiTotalValueSum2", String.format("%.2f", apiTotalValueSum2));
|
|
||||||
mergeMap.put("apiSum2", String.format("%.2f", apiSum2));
|
|
||||||
mergeMap.put("productTotalValueCountSum", String.format("%.2f", productTotalValueCountSum));
|
|
||||||
mergeMap.put("productTotalValueAmountSum", String.format("%.2f", productTotalValueAmountSum));
|
|
||||||
mergeMap.put("productSum", String.format("%.2f", productSum));
|
|
||||||
mergeMap.put("productTotalValueCountSum2", String.format("%.2f", productTotalValueCountSum2));
|
|
||||||
mergeMap.put("productTotalValueAmountSum2", String.format("%.2f", productTotalValueAmountSum2));
|
|
||||||
mergeMap.put("productSum2", String.format("%.2f", productSum2));
|
|
||||||
mergeMap.put("totalValueCountSum", String.format("%.2f", totalValueCountSum));
|
|
||||||
mergeMap.put("totalValueCountSum2", String.format("%.2f", totalValueCountSum2));
|
|
||||||
mergeMap.put("totalValueAmountSum", String.format("%.2f", totalValueAmountSum));
|
|
||||||
mergeMap.put("totalValueAmountSum2", String.format("%.2f", totalValueAmountSum2));
|
|
||||||
mergeMap.put("sum", String.format("%.2f", sum));
|
|
||||||
mergeMap.put("sum2", String.format("%.2f", sum2));
|
|
||||||
mergeMap.put("list", o1List);
|
|
||||||
|
|
||||||
return mergeMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Inner Classes ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수수료 조회 결과를 담는 내부 클래스
|
|
||||||
*/
|
|
||||||
private static class CommissionResult {
|
|
||||||
private CommissionDTO data;
|
|
||||||
private String error;
|
|
||||||
|
|
||||||
static CommissionResult success(CommissionDTO data) {
|
|
||||||
CommissionResult result = new CommissionResult();
|
|
||||||
result.data = data;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static CommissionResult error(String message) {
|
|
||||||
CommissionResult result = new CommissionResult();
|
|
||||||
result.error = message;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean hasError() {
|
|
||||||
return error != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
CommissionDTO getData() {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
String getError() {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.dto;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class CommissionDTO {
|
|
||||||
|
|
||||||
private String status;
|
|
||||||
private String apiTotalValueSum;
|
|
||||||
private String apiSum;
|
|
||||||
private String apiTotalValueSum2;
|
|
||||||
private String apiSum2;
|
|
||||||
private String productTotalValueCountSum;
|
|
||||||
private String productTotalValueAmountSum;
|
|
||||||
private String productSum;
|
|
||||||
private String productTotalValueCountSum2;
|
|
||||||
private String productTotalValueAmountSum2;
|
|
||||||
private String productSum2;
|
|
||||||
private String totalValueCountSum;
|
|
||||||
private String totalValueCountSum2;
|
|
||||||
private String totalValueAmountSum;
|
|
||||||
private String totalValueAmountSum2;
|
|
||||||
private String sum;
|
|
||||||
private String sum2;
|
|
||||||
private List<CommissionList> list;
|
|
||||||
private String documentFormatNo;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created by jskim on 17. 01. 26.
|
|
||||||
*
|
|
||||||
* @author jskim
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public class CommissionList {
|
|
||||||
|
|
||||||
private String paymentTargetName;
|
|
||||||
private String paymentTargetType;
|
|
||||||
private String value;
|
|
||||||
private String minimumAmount;
|
|
||||||
private String maximumAmount;
|
|
||||||
private String totalCount;
|
|
||||||
private String totalCount2;
|
|
||||||
private String totalValue;
|
|
||||||
private String totalValue2;
|
|
||||||
private String minimumAmount2;
|
|
||||||
private String maximumAmount2;
|
|
||||||
private String paymentBaseName;
|
|
||||||
private String paymentTimingName;
|
|
||||||
private String valueDivisionName;
|
|
||||||
private String sum;
|
|
||||||
private String sum2;
|
|
||||||
private String detailFeeTypeName;
|
|
||||||
private String value2;
|
|
||||||
private String changeReason2;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.dto;
|
|
||||||
|
|
||||||
import io.swagger.annotations.ApiModelProperty;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class CommissionSearch {
|
|
||||||
|
|
||||||
@ApiModelProperty(value = "대상년")
|
|
||||||
private String year;
|
|
||||||
|
|
||||||
@ApiModelProperty(value = "대상월")
|
|
||||||
private String month;
|
|
||||||
|
|
||||||
private String partnerCode;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.exception;
|
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created by ybsong on 16. 12. 7.
|
|
||||||
*
|
|
||||||
* @author ybsong
|
|
||||||
*/
|
|
||||||
public class ErrorUtil {
|
|
||||||
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message, String description) {
|
|
||||||
return new ResponseEntity<>(new ErrorVM(message, description), status);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message) {
|
|
||||||
return create(status, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ResponseEntity<ErrorVM> create(String message, String description) {
|
|
||||||
return create(HttpStatus.BAD_REQUEST, message, description);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ResponseEntity<ErrorVM> create(String message) {
|
|
||||||
return create(message, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.exception;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* View Model for transferring error message with a list of field errors.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public class ErrorVM implements Serializable {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final String message;
|
|
||||||
private final String description;
|
|
||||||
|
|
||||||
private List<FieldErrorVM> fieldErrors;
|
|
||||||
|
|
||||||
public ErrorVM(String message) {
|
|
||||||
this(message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ErrorVM(String message, String description) {
|
|
||||||
this.message = message;
|
|
||||||
this.description = description;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ErrorVM(String message, String description, List<FieldErrorVM> fieldErrors) {
|
|
||||||
this.message = message;
|
|
||||||
this.description = description;
|
|
||||||
this.fieldErrors = fieldErrors;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void add(String objectName, String field, String message) {
|
|
||||||
if (fieldErrors == null) {
|
|
||||||
fieldErrors = new ArrayList<>();
|
|
||||||
}
|
|
||||||
fieldErrors.add(new FieldErrorVM(objectName, field, message));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.exception;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
|
|
||||||
public class FieldErrorVM implements Serializable {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final String objectName;
|
|
||||||
|
|
||||||
private final String field;
|
|
||||||
|
|
||||||
private final String message;
|
|
||||||
|
|
||||||
public FieldErrorVM(String dto, String field, String message) {
|
|
||||||
this.objectName = dto;
|
|
||||||
this.field = field;
|
|
||||||
this.message = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getObjectName() {
|
|
||||||
return objectName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getField() {
|
|
||||||
return field;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getMessage() {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package com.eactive.apim.portal.apps.commission.service;
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.commission.constant.Constants;
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
|
||||||
import java.util.Map;
|
|
||||||
import javax.inject.Inject;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.http.HttpEntity;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.client.HttpStatusCodeException;
|
|
||||||
import org.springframework.web.client.RestClientException;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API 호출 서비스
|
|
||||||
* RestTemplate 기반으로 외부 시스템과 HTTP 통신을 수행합니다.
|
|
||||||
*
|
|
||||||
* @author ybsong
|
|
||||||
* @since 2017-03-02
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@Transactional
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ExternalService {
|
|
||||||
|
|
||||||
private final Logger log = LoggerFactory.getLogger(ExternalService.class);
|
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
private final PortalPropertyService portalPropertyService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP POST 요청을 통해 외부 API를 호출하고 응답을 받습니다.
|
|
||||||
*
|
|
||||||
* @param url 호출할 API URL
|
|
||||||
* @param payload 요청 본문 (JSON 문자열)
|
|
||||||
* @return API 응답 본문 (문자열), 오류 발생 시 null
|
|
||||||
*/
|
|
||||||
public String getResponseHttpPost(String url, String payload) {
|
|
||||||
try {
|
|
||||||
|
|
||||||
Map<String, String> portalProperties = portalPropertyService.getPortalPropertiesAsMap("Portal");
|
|
||||||
|
|
||||||
// HTTP 헤더 설정
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
headers.set(Constants.APIM_FOR_OBP_KEY, portalProperties.getOrDefault("obp.api_key","APIMPT-0002-QVBJTVBULTAwMDI="));
|
|
||||||
headers.set(Constants.APIM_CODE_FOR_OBP_KEY, portalProperties.getOrDefault("obp.partner_code","100001-01"));
|
|
||||||
|
|
||||||
// HTTP 요청 엔티티 생성 (헤더 + 본문)
|
|
||||||
HttpEntity<String> requestEntity = new HttpEntity<>(payload, headers);
|
|
||||||
|
|
||||||
// POST 요청 실행
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
|
|
||||||
|
|
||||||
// 응답 본문 반환
|
|
||||||
return response.getBody();
|
|
||||||
|
|
||||||
} catch (HttpStatusCodeException e) {
|
|
||||||
// 4xx, 5xx 에러더라도 응답 본문이 있으면 반환 (에러 메시지 포함)
|
|
||||||
String responseBody = e.getResponseBodyAsString();
|
|
||||||
log.warn("HTTP error from external API: url={}, status={}, body={}", url, e.getStatusCode(), responseBody);
|
|
||||||
if (responseBody != null && !responseBody.isEmpty()) {
|
|
||||||
return responseBody;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (RestClientException e) {
|
|
||||||
log.error("Failed to call external API: url={}, error={}", url, e.getMessage(), e);
|
|
||||||
return null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Unexpected error during HTTP POST: url={}", url, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import org.springframework.ui.Model;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class FaqController {
|
public class FaqController {
|
||||||
@@ -47,4 +48,16 @@ public class FaqController {
|
|||||||
|
|
||||||
return "apps/community/mainFaqList";
|
return "apps/community/mainFaqList";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/faq_view")
|
||||||
|
public String view(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
|
if (id == null) {
|
||||||
|
return "redirect:/faq_list";
|
||||||
|
}
|
||||||
|
|
||||||
|
FaqDTO faq = faqFacade.getFaq(id);
|
||||||
|
model.addAttribute("faq", faq);
|
||||||
|
|
||||||
|
return "apps/community/mainFaqDetail";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ import org.springframework.data.domain.Pageable;
|
|||||||
|
|
||||||
public interface FaqFacade {
|
public interface FaqFacade {
|
||||||
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
|
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
|
||||||
|
|
||||||
|
FaqDTO getFaq(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,4 +34,9 @@ public class FaqFacadeImpl implements FaqFacade {
|
|||||||
return faqPage.map(faqMapper::map);
|
return faqPage.map(faqMapper::map);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FaqDTO getFaq(String id) {
|
||||||
|
return faqMapper.map(faqService.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class InquiryDTO {
|
|||||||
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
|
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
|
||||||
private String inquiryDetail;
|
private String inquiryDetail;
|
||||||
|
|
||||||
@Pattern(regexp = "^(PENDING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
@Pattern(regexp = "^(PENDING|REVIEWING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
||||||
private String inquiryStatus;
|
private String inquiryStatus;
|
||||||
|
|
||||||
private String inquirerName;
|
private String inquirerName;
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ public class UserSessionService {
|
|||||||
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
||||||
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
||||||
|
|
||||||
|
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
||||||
|
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
||||||
|
private static final String DEFAULT_KEEPALIVE_ENABLED = "false";
|
||||||
|
|
||||||
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
|
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
|
||||||
private static final long CLEANUP_MARGIN_MINUTES = 1;
|
private static final long CLEANUP_MARGIN_MINUTES = 1;
|
||||||
|
|
||||||
@@ -133,6 +137,20 @@ public class UserSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB(PortalProperty)에서 세션 유지(타임아웃 무시) 기능 활성화 여부 조회.
|
||||||
|
* ⚠️ prod 가드는 포함하지 않는다 — prod 차단은 호출부(GlobalControllerAdvice)에서 {@code !prod && isKeepAliveEnabled()}로 처리.
|
||||||
|
*/
|
||||||
|
public boolean isKeepAliveEnabled() {
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROPERTY_GROUP,
|
||||||
|
KEEPALIVE_PROPERTY_NAME,
|
||||||
|
DEFAULT_KEEPALIVE_ENABLED,
|
||||||
|
"세션 유지(타임아웃 무시) 기능 활성화 여부 (true/false, 비운영 전용)"
|
||||||
|
);
|
||||||
|
return value != null && Boolean.parseBoolean(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
|
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.Profiles;
|
||||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
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.config.PortalProperties;
|
||||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
|
import com.eactive.apim.portal.common.security.ClientGuardService;
|
||||||
|
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
public class GlobalControllerAdvice {
|
public class GlobalControllerAdvice {
|
||||||
@@ -16,15 +18,18 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PageService pageService;
|
private PageService pageService;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private PortalPropertyService portalPropertyService;
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private PortalProperties portalProperties;
|
private PortalProperties portalProperties;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private UserSessionService userSessionService;
|
private UserSessionService userSessionService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ClientGuardService clientGuardService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private Environment environment;
|
||||||
|
|
||||||
@ModelAttribute("breadcrumb")
|
@ModelAttribute("breadcrumb")
|
||||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||||
String currentPath = request.getRequestURI();
|
String currentPath = request.getRequestURI();
|
||||||
@@ -37,17 +42,6 @@ public class GlobalControllerAdvice {
|
|||||||
return pageService.getPageName(currentPath);
|
return pageService.getPageName(currentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ModelAttribute("designSurveyEnabled")
|
|
||||||
public boolean isDesignSurveyEnabled() {
|
|
||||||
String value = portalPropertyService.getOrCreateProperty(
|
|
||||||
"Portal",
|
|
||||||
"ui.design-survey",
|
|
||||||
"false",
|
|
||||||
"네비게이션 바 디자인 설문 활성화 여부 (true/false)"
|
|
||||||
);
|
|
||||||
return "true".equalsIgnoreCase(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ModelAttribute("showTestAuthNotice")
|
@ModelAttribute("showTestAuthNotice")
|
||||||
public boolean showTestAuthNotice() {
|
public boolean showTestAuthNotice() {
|
||||||
return portalProperties.isTestAuthNoticeEnabled();
|
return portalProperties.isTestAuthNoticeEnabled();
|
||||||
@@ -70,4 +64,33 @@ public class GlobalControllerAdvice {
|
|||||||
public int sessionTimeoutMinutes() {
|
public int sessionTimeoutMinutes() {
|
||||||
return userSessionService.getSessionTimeoutMinutes();
|
return userSessionService.getSessionTimeoutMinutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 유지(타임아웃 무시) 기능 사용 가능 여부.
|
||||||
|
* 비운영(!prod) + DB property(Portal/session.keepalive.enabled=Y)일 때만 true.
|
||||||
|
* prod 환경에서는 property 값과 무관하게 항상 false → UI 체크박스 미렌더.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("sessionKeepAliveAllowed")
|
||||||
|
public boolean sessionKeepAliveAllowed() {
|
||||||
|
boolean prod = environment.acceptsProfiles(Profiles.of("prod"));
|
||||||
|
return !prod && userSessionService.isKeepAliveEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클라이언트 가드 - 우클릭(contextmenu) 차단 활성화 여부.
|
||||||
|
* PortalProperty(Portal/djb.client-guard.contextmenu.enabled)에서 조회.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("clientGuardContextmenu")
|
||||||
|
public boolean clientGuardContextmenu() {
|
||||||
|
return clientGuardService.isContextmenuBlockEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클라이언트 가드 - DevTools 감지 시 화면 삭제 활성화 여부.
|
||||||
|
* PortalProperty(Portal/djb.client-guard.devtools.enabled)에서 조회.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("clientGuardDevtools")
|
||||||
|
public boolean clientGuardDevtools() {
|
||||||
|
return clientGuardService.isDevtoolsGuardEnabled();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.eactive.apim.portal.common.security;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클라이언트측 하드닝(우클릭 차단 · DevTools 감지) 토글을 DB(PortalProperty)에서 조회한다.
|
||||||
|
*
|
||||||
|
* <p>두 기능은 서로 독립된 property 로 on/off 한다. group 은 기존 {@code Portal} 을 재사용하여
|
||||||
|
* {@link PortalPropertyService#getOrCreateProperty} 의 자동 생성이 동작하도록 한다.</p>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code djb.client-guard.contextmenu.enabled} - 우클릭(contextmenu) 차단</li>
|
||||||
|
* <li>{@code djb.client-guard.devtools.enabled} - DevTools 감지 시 화면 삭제</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ClientGuardService {
|
||||||
|
|
||||||
|
private static final String GROUP = "Portal";
|
||||||
|
private static final String NAME_CONTEXTMENU = "djb.client-guard.contextmenu.enabled";
|
||||||
|
private static final String NAME_DEVTOOLS = "djb.client-guard.devtools.enabled";
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** 우클릭(contextmenu) 차단 활성화 여부 */
|
||||||
|
public boolean isContextmenuBlockEnabled() {
|
||||||
|
return read(NAME_CONTEXTMENU, "우클릭(contextmenu) 차단");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DevTools 감지 시 화면 삭제 활성화 여부 */
|
||||||
|
public boolean isDevtoolsGuardEnabled() {
|
||||||
|
return read(NAME_DEVTOOLS, "DevTools 감지 시 화면 삭제");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean read(String name, String desc) {
|
||||||
|
return Boolean.parseBoolean(
|
||||||
|
portalPropertyService.getOrCreateProperty(GROUP, name, "false", desc).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||||
clientIp, request.getHeader("User-Agent"));
|
clientIp, request.getHeader("User-Agent"));
|
||||||
|
|
||||||
|
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
|
||||||
|
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
|
||||||
|
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||||
|
|
||||||
// 로그인 성공 시 세션 정보 로깅
|
// 로그인 성공 시 세션 정보 로깅
|
||||||
logLoginSuccess(request, session, username);
|
logLoginSuccess(request, session, username);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.eactive.apim.portal.config;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.repository.PortalPropertyRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.ApplicationListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기동 시 PTL_PROPERTY 무결성 점검.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY 는 (PROPERTY_GROUP_NAME, PROPERTY_NAME) 조합이 유일해야 하지만, 운영 정책상
|
||||||
|
* DB에 유니크 제약을 걸지 않는다. 대신 부팅 완료 시점에 중복 키를 스캔하여 존재하면 ERROR 로그로
|
||||||
|
* 남긴다. (Spring Data 리포지토리는 인터페이스라 초기화 훅이 없어, 리포지토리를 사용하는 startup
|
||||||
|
* 리스너로 점검한다.)</p>
|
||||||
|
*
|
||||||
|
* <p>중복은 {@code getOrCreateProperty} 최초 조회가 동시 요청으로 경합할 때 각각 INSERT되며 생길 수
|
||||||
|
* 있다. 제약이 없으므로 중복 행은 자동 제거되지 않는다 — 로그를 보고 수동 정리해야 한다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PortalPropertyDuplicateChecker implements ApplicationListener<ApplicationReadyEvent> {
|
||||||
|
|
||||||
|
private final PortalPropertyRepository portalPropertyRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||||
|
List<Object[]> duplicates;
|
||||||
|
try {
|
||||||
|
duplicates = portalPropertyRepository.findDuplicatePropertyKeys();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[PortalProperty 무결성] 중복 점검 쿼리 실패", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicates == null || duplicates.isEmpty()) {
|
||||||
|
log.info("[PortalProperty 무결성] (PROPERTY_GROUP_NAME, PROPERTY_NAME) 중복 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) (group, name) 은 유일해야 한다
|
||||||
|
// 2) 중복된 값 목록을 남긴다
|
||||||
|
log.error("[PortalProperty 무결성] (PROPERTY_GROUP_NAME, PROPERTY_NAME) 조합은 유일해야 합니다. "
|
||||||
|
+ "DB 유니크 제약이 없어 중복 {}건이 방치되어 있습니다. 수동 정리 필요:", duplicates.size());
|
||||||
|
for (Object[] row : duplicates) {
|
||||||
|
log.error(" - PROPERTY_GROUP_NAME='{}', PROPERTY_NAME='{}', count={}", row[0], row[1], row[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.eactive.apim.portal.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.boot.system.ApplicationPid;
|
||||||
|
import org.springframework.context.ApplicationListener;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints a startup banner to stdout once boot is complete.
|
||||||
|
*
|
||||||
|
* <p>Uses {@link System#out} directly (not a logger) so the info is shown even when
|
||||||
|
* the logback console appender is disabled (CONSOLE_LOG_ENABLED=false).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class StartupInfoPrinter implements ApplicationListener<ApplicationReadyEvent> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||||
|
Environment env = event.getApplicationContext().getEnvironment();
|
||||||
|
|
||||||
|
String port = env.getProperty("server.port", "8080");
|
||||||
|
String contextPath = env.getProperty("server.servlet.context-path", "/");
|
||||||
|
if (!contextPath.startsWith("/")) {
|
||||||
|
contextPath = "/" + contextPath;
|
||||||
|
}
|
||||||
|
String url = "http://localhost:" + port + contextPath;
|
||||||
|
|
||||||
|
String[] active = env.getActiveProfiles();
|
||||||
|
String profile = active.length == 0
|
||||||
|
? String.join(", ", env.getDefaultProfiles())
|
||||||
|
: String.join(", ", active);
|
||||||
|
|
||||||
|
String instanceName = env.getProperty("inst.Name", System.getProperty("inst.Name", "unknown"));
|
||||||
|
String pid = new ApplicationPid().toString();
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(System.lineSeparator());
|
||||||
|
sb.append("=====================================================").append(System.lineSeparator());
|
||||||
|
sb.append(" EAPIM Portal - startup complete").append(System.lineSeparator());
|
||||||
|
sb.append("=====================================================").append(System.lineSeparator());
|
||||||
|
sb.append(" URL : ").append(url).append(System.lineSeparator());
|
||||||
|
sb.append(" Spring profile : ").append(profile).append(System.lineSeparator());
|
||||||
|
sb.append(" Instance name : ").append(instanceName).append(System.lineSeparator());
|
||||||
|
sb.append(" PID : ").append(pid).append(System.lineSeparator());
|
||||||
|
sb.append("=====================================================").append(System.lineSeparator());
|
||||||
|
|
||||||
|
System.out.println(sb);
|
||||||
|
System.out.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,9 +160,15 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
&& writerId.equals(current.getId())
|
&& writerId.equals(current.getId())
|
||||||
&& entity.getInquiry() != null
|
&& entity.getInquiry() != null
|
||||||
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
|
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
|
||||||
String writerName = writerId != null ? writerNames.get(writerId) : null;
|
String writerName;
|
||||||
if (writerName == null || writerName.isEmpty()) {
|
if ("Y".equals(entity.getAdminYn())) {
|
||||||
writerName = "(알 수 없음)";
|
// 관리자 댓글은 실명 대신 "관리자"로만 표기 (신원 비노출)
|
||||||
|
writerName = "관리자";
|
||||||
|
} else {
|
||||||
|
writerName = writerId != null ? writerNames.get(writerId) : null;
|
||||||
|
if (writerName == null || writerName.isEmpty()) {
|
||||||
|
writerName = "(알 수 없음)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return InquiryCommentDTO.builder()
|
return InquiryCommentDTO.builder()
|
||||||
.id(entity.getId())
|
.id(entity.getId())
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.community.qna.constant;
|
|||||||
public final class DjbInquiryStatus {
|
public final class DjbInquiryStatus {
|
||||||
|
|
||||||
public static final String PENDING = "PENDING";
|
public static final String PENDING = "PENDING";
|
||||||
|
public static final String REVIEWING = "REVIEWING";
|
||||||
public static final String RESPONDED = "RESPONDED";
|
public static final String RESPONDED = "RESPONDED";
|
||||||
public static final String CLOSED = "CLOSED";
|
public static final String CLOSED = "CLOSED";
|
||||||
|
|
||||||
@@ -12,4 +13,37 @@ public final class DjbInquiryStatus {
|
|||||||
public static boolean isClosed(String status) {
|
public static boolean isClosed(String status) {
|
||||||
return CLOSED.equals(status);
|
return CLOSED.equals(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 코드에 대응하는 사용자 노출용 한글명을 반환한다.
|
||||||
|
* (미매칭/null은 대기 상태로 간주)
|
||||||
|
*/
|
||||||
|
public static String displayName(String status) {
|
||||||
|
if (RESPONDED.equals(status)) {
|
||||||
|
return "답변완료";
|
||||||
|
}
|
||||||
|
if (CLOSED.equals(status)) {
|
||||||
|
return "종료";
|
||||||
|
}
|
||||||
|
if (REVIEWING.equals(status)) {
|
||||||
|
return "검토중";
|
||||||
|
}
|
||||||
|
return "답변대기"; // PENDING 및 기본값
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 코드에 대응하는 배지 CSS modifier 클래스를 반환한다.
|
||||||
|
*/
|
||||||
|
public static String badgeClass(String status) {
|
||||||
|
if (RESPONDED.equals(status)) {
|
||||||
|
return "inquiry-status-badge--completed";
|
||||||
|
}
|
||||||
|
if (CLOSED.equals(status)) {
|
||||||
|
return "inquiry-status-badge--closed";
|
||||||
|
}
|
||||||
|
if (REVIEWING.equals(status)) {
|
||||||
|
return "inquiry-status-badge--reviewing";
|
||||||
|
}
|
||||||
|
return "inquiry-status-badge--pending"; // PENDING 및 기본값
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ server:
|
|||||||
servlet:
|
servlet:
|
||||||
context-path: /
|
context-path: /
|
||||||
session:
|
session:
|
||||||
timeout: 10m
|
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
|
||||||
|
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
|
||||||
|
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
|
||||||
|
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
|
||||||
|
# timeout: 10m
|
||||||
cookie:
|
cookie:
|
||||||
name: PORTAL_JSESSIONID
|
name: JSESSIONID_PORTAL
|
||||||
encoding:
|
encoding:
|
||||||
charset: UTF-8
|
charset: UTF-8
|
||||||
port: '39130'
|
port: '39130'
|
||||||
@@ -314,6 +318,9 @@ page:
|
|||||||
faq_list:
|
faq_list:
|
||||||
name: FAQ
|
name: FAQ
|
||||||
path: "/faq_list"
|
path: "/faq_list"
|
||||||
|
faq_detail:
|
||||||
|
name: FAQ
|
||||||
|
path: "/faq_view"
|
||||||
inquiry:
|
inquiry:
|
||||||
name: "Q&A"
|
name: "Q&A"
|
||||||
path: "/inquiry"
|
path: "/inquiry"
|
||||||
@@ -378,9 +385,6 @@ page:
|
|||||||
myapikey_modify_step3:
|
myapikey_modify_step3:
|
||||||
name: "앱 수정 요청 완료"
|
name: "앱 수정 요청 완료"
|
||||||
path: "/myapikey/modify/step3"
|
path: "/myapikey/modify/step3"
|
||||||
commission:
|
|
||||||
name: "과금 관리"
|
|
||||||
path: "/commission/manage"
|
|
||||||
api_statistics:
|
api_statistics:
|
||||||
name: "이용 통계"
|
name: "이용 통계"
|
||||||
path: "/statistics/api"
|
path: "/statistics/api"
|
||||||
|
|||||||
@@ -663,75 +663,6 @@ hr {
|
|||||||
--shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
|
--shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-survey-bar {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 48px;
|
|
||||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
|
||||||
z-index: 400;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
.design-survey-bar .survey-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.design-survey-bar .survey-label {
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.design-survey-bar .survey-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.design-survey-bar .survey-btn {
|
|
||||||
padding: 6px 16px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: transparent;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.design-survey-bar .survey-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-color: #ffffff;
|
|
||||||
}
|
|
||||||
.design-survey-bar .survey-btn.active {
|
|
||||||
background: #ffffff;
|
|
||||||
color: #667eea;
|
|
||||||
border-color: #ffffff;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.design-survey-bar {
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
.design-survey-bar .survey-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.design-survey-bar .survey-btn {
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body.design-survey-active .global-header {
|
|
||||||
margin-top: 48px;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
body.design-survey-active .global-header {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.blind {
|
.blind {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -5333,14 +5264,44 @@ select.form-control {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-timer-mobile {
|
.session-float {
|
||||||
margin-right: 8px;
|
position: fixed;
|
||||||
font-size: 12px;
|
top: 16px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
.session-timer-mobile .session-timer-text {
|
.session-float .session-keepalive {
|
||||||
min-width: 34px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid #e0e0e0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.session-float .session-keepalive input {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.session-float {
|
||||||
|
top: 12px;
|
||||||
|
right: 72px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.data-table {
|
.data-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
@@ -16966,6 +16927,9 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
.inquiry-status-badge--pending {
|
.inquiry-status-badge--pending {
|
||||||
background-color: #8c959f;
|
background-color: #8c959f;
|
||||||
}
|
}
|
||||||
|
.inquiry-status-badge--reviewing {
|
||||||
|
background-color: #e08e0b;
|
||||||
|
}
|
||||||
.inquiry-status-badge--closed {
|
.inquiry-status-badge--closed {
|
||||||
background-color: #4a5560;
|
background-color: #4a5560;
|
||||||
}
|
}
|
||||||
@@ -17467,343 +17431,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transform: translateY(4px);
|
transform: translateY(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commission-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrap {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: #FFFFFF;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInfo {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBox {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.searchBox.row-two {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.searchBox label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1A1A2E;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
.searchBox .form-control {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: 1px solid #E2E8F0;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-inline {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.form-inline .period-selects {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.form-inline .period-selects .period-unit {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnBox {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.btnBox .btn + .btn {
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#printBtn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commission-notice {
|
|
||||||
color: #dc3545;
|
|
||||||
margin-top: 30px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voffset3 {
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color_red {
|
|
||||||
color: #FF6B6B;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color_blue {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color_darkRed {
|
|
||||||
color: #8b0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-responsive {
|
|
||||||
overflow-x: auto;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.table th {
|
|
||||||
background: #F8FAFC;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1A1A2E;
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid #E2E8F0;
|
|
||||||
}
|
|
||||||
.table td {
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid #E2E8F0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-bordered {
|
|
||||||
border: 1px solid #E2E8F0;
|
|
||||||
}
|
|
||||||
.table-bordered th,
|
|
||||||
.table-bordered td {
|
|
||||||
border: 1px solid #E2E8F0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-type {
|
|
||||||
border: 2px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commission-table-wrapper {
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
.commission-table-wrapper .table {
|
|
||||||
border: 2px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-row {
|
|
||||||
background-color: #fff3cd !important;
|
|
||||||
}
|
|
||||||
.summary-row td {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.summary-row .summary-amount {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-warning {
|
|
||||||
background-color: #fff3cd !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glyphicon-search::before {
|
|
||||||
content: "🔍";
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-table {
|
|
||||||
border-top: 10px solid #64748B;
|
|
||||||
width: 100%;
|
|
||||||
border-bottom: 2px solid #1A1A2E;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
.info-table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 16px 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.info-table td {
|
|
||||||
padding: 16px 24px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commission-table {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
.commission-table th,
|
|
||||||
.commission-table td {
|
|
||||||
border: 1px solid #999 !important;
|
|
||||||
padding: 16px 24px;
|
|
||||||
}
|
|
||||||
.commission-table td {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_ygreen {
|
|
||||||
background-color: aquamarine;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_skyBlue {
|
|
||||||
background-color: skyblue;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_yellow {
|
|
||||||
background-color: yellow;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
background: #FFFFFF;
|
|
||||||
}
|
|
||||||
.searchBox,
|
|
||||||
.searchInfo,
|
|
||||||
#printButton,
|
|
||||||
#backButton,
|
|
||||||
#printBtn {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
@page {
|
|
||||||
size: auto;
|
|
||||||
margin: 0 0cm;
|
|
||||||
}
|
|
||||||
html {
|
|
||||||
margin: 0 0cm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media screen and (max-width: 1024px) {
|
|
||||||
.wrap {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.searchBox {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.searchBox.row-two {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.form-inline {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.form-inline label {
|
|
||||||
min-width: auto;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.form-inline .form-control {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.form-inline--period .period-selects {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.form-inline--period .period-selects .form-control {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.form-inline--period .period-selects .period-unit {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #1A1A2E;
|
|
||||||
}
|
|
||||||
.btnBox {
|
|
||||||
margin-left: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.btnBox .btn {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.table-responsive {
|
|
||||||
overflow-x: scroll;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body.commission-print-page {
|
|
||||||
font-size: 12px;
|
|
||||||
width: 600px;
|
|
||||||
margin: 90px;
|
|
||||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
body.commission-print-page div {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
body.commission-print-page table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
body.commission-print-page table th,
|
|
||||||
body.commission-print-page table td {
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
body.commission-print-page table tr {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
body.commission-print-page h1 {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
body.commission-print-page h1 img {
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
body.commission-print-page h2 {
|
|
||||||
margin-top: 40px;
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
body.commission-print-page p {
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
body.commission-print-page p[style*="float: right"] {
|
|
||||||
margin-top: 25px;
|
|
||||||
}
|
|
||||||
body.commission-print-page .btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
body.commission-print-page .btn-primary {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
body.commission-print-page .btn-primary:hover {
|
|
||||||
background: #5a67d8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terms-page {
|
.terms-page {
|
||||||
min-height: calc(100vh - 140px);
|
min-height: calc(100vh - 140px);
|
||||||
background: #F8FAFC;
|
background: #F8FAFC;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* client-guard.js
|
||||||
|
*
|
||||||
|
* 클라이언트측 하드닝. 서버가 내려준 window.__CLIENT_GUARD__ 플래그에 따라 각 기능을 독립 적용한다.
|
||||||
|
* - contextmenu: 우클릭(컨텍스트 메뉴) 차단
|
||||||
|
* - devtools : 창 크기 휴리스틱으로 DevTools 열림 감지 시 화면 전체 요소 영구 삭제
|
||||||
|
*
|
||||||
|
* 주의(한계): 창 크기 휴리스틱은 오탐/미탐이 있다(줌·북마크바·사이드패널·좁은창=오탐, undock 창=미탐).
|
||||||
|
* JS 비활성화·breakpoint·DOM 편집으로 우회 가능하므로 실질 보안이 아니라 억제용 데코이다.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var cfg = window.__CLIENT_GUARD__ || {};
|
||||||
|
|
||||||
|
// ── 우클릭(contextmenu) 차단 ─────────────────────────────────────────
|
||||||
|
if (cfg.contextmenu) {
|
||||||
|
document.addEventListener('contextmenu', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DevTools 감지 → 화면 영구 삭제 ───────────────────────────────────
|
||||||
|
if (cfg.devtools) {
|
||||||
|
var THRESHOLD = 160; // 도킹된 DevTools 패널이 차지하는 최소 폭/높이(px) 추정값
|
||||||
|
var POLL_MS = 500;
|
||||||
|
var wiped = false;
|
||||||
|
|
||||||
|
function isDevtoolsOpen() {
|
||||||
|
var widthGap = window.outerWidth - window.innerWidth;
|
||||||
|
var heightGap = window.outerHeight - window.innerHeight;
|
||||||
|
return widthGap > THRESHOLD || heightGap > THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wipe() {
|
||||||
|
if (wiped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wiped = true;
|
||||||
|
// 화면 전체 요소 영구 삭제 (닫아도 복구되지 않음, 새로고침 필요)
|
||||||
|
try {
|
||||||
|
document.documentElement.replaceChildren();
|
||||||
|
} catch (ignore) {
|
||||||
|
if (document.body) {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
if (!wiped && isDevtoolsOpen()) {
|
||||||
|
wipe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setInterval(check, POLL_MS);
|
||||||
|
window.addEventListener('resize', check);
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -79,8 +79,9 @@
|
|||||||
li.innerHTML = ''
|
li.innerHTML = ''
|
||||||
+ '<div class="djb-comment-meta">'
|
+ '<div class="djb-comment-meta">'
|
||||||
+ ' <div class="djb-comment-meta-left">'
|
+ ' <div class="djb-comment-meta-left">'
|
||||||
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
|
+ (c.adminYn === 'Y'
|
||||||
+ (c.adminYn === 'Y' ? ' <span class="djb-comment-admin-badge">관리자</span>' : '')
|
? ' <span class="djb-comment-admin-badge">관리자</span>'
|
||||||
|
: ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>')
|
||||||
+ ' </div>'
|
+ ' </div>'
|
||||||
+ ' <div class="djb-comment-meta-right">'
|
+ ' <div class="djb-comment-meta-right">'
|
||||||
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
||||||
|
|||||||
@@ -32,12 +32,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모바일 헤더(햄버거 버튼 좌측)용 타이머
|
// -----------------------------------------------------------------------------
|
||||||
.session-timer-mobile {
|
// 우측 상단 Float 세션 타이머 위젯
|
||||||
margin-right: $spacing-sm;
|
// 데스크톱: 헤더 우측(유저 메뉴 우측)에 상단 정렬로 고정(fixed).
|
||||||
font-size: $font-size-xs;
|
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 배치.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
.session-float {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
.session-timer-text {
|
// 세션 유지 체크박스 (비운영 전용, 조건부 렌더)
|
||||||
min-width: 34px;
|
.session-keepalive {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid #e0e0e0;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $text-gray;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치.
|
||||||
|
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
.session-float {
|
||||||
|
top: 12px;
|
||||||
|
right: 72px;
|
||||||
|
padding: 4px 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,80 +33,6 @@
|
|||||||
--shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
|
--shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================
|
|
||||||
// Design Survey Bar
|
|
||||||
// ===========================
|
|
||||||
.design-survey-bar {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 48px;
|
|
||||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
|
||||||
z-index: 400;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
|
|
||||||
.survey-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.survey-label {
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.survey-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.survey-btn {
|
|
||||||
padding: 6px 16px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: transparent;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: #ffffff;
|
|
||||||
color: #667eea;
|
|
||||||
border-color: #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
height: 40px;
|
|
||||||
.survey-label { display: none; }
|
|
||||||
.survey-btn { padding: 4px 12px; font-size: 12px; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body offset when survey is active
|
|
||||||
body.design-survey-active {
|
|
||||||
.global-header { margin-top: 48px; }
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.global-header { margin-top: 40px; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 디자인 변형 스타일은 JavaScript에서 동적으로 적용됩니다.
|
|
||||||
// header_container.html의 DESIGN_OPTIONS 참조
|
|
||||||
|
|
||||||
// Blind text for screen readers
|
// Blind text for screen readers
|
||||||
.blind {
|
.blind {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -63,7 +63,6 @@
|
|||||||
@use 'pages/org-register' as *;
|
@use 'pages/org-register' as *;
|
||||||
@use 'pages/partnership' as *;
|
@use 'pages/partnership' as *;
|
||||||
@use 'pages/user-management' as *;
|
@use 'pages/user-management' as *;
|
||||||
@use 'pages/commission' as *;
|
|
||||||
@use 'pages/terms-agreements' as *;
|
@use 'pages/terms-agreements' as *;
|
||||||
@use 'pages/service' as *;
|
@use 'pages/service' as *;
|
||||||
@use 'pages/api-statistics' as *;
|
@use 'pages/api-statistics' as *;
|
||||||
|
|||||||
@@ -1,412 +0,0 @@
|
|||||||
@use '../abstracts/variables' as *;
|
|
||||||
@use '../abstracts/color-functions' as *;
|
|
||||||
@use '../abstracts/mixins' as *;
|
|
||||||
|
|
||||||
// Commission Pages Styles
|
|
||||||
// 수수료 관리 및 청구서 출력 페이지 스타일
|
|
||||||
|
|
||||||
|
|
||||||
// ==================== Commission Management Page ====================
|
|
||||||
|
|
||||||
// Commission Container
|
|
||||||
.commission-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrap {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: $white;
|
|
||||||
padding: $spacing-lg;
|
|
||||||
border-radius: $border-radius-md;
|
|
||||||
box-shadow: $shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInfo {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: $spacing-sm;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search Box
|
|
||||||
.searchBox {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: $border-radius-md;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
&.row-two {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-weight: $font-weight-medium;
|
|
||||||
color: $text-dark;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: 1px solid $border-gray;
|
|
||||||
border-radius: $border-radius-sm;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-inline {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
// 조회기간 [년] [월] 컨테이너 - PC 기본 스타일
|
|
||||||
.period-selects {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
.period-unit {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnBox {
|
|
||||||
margin-left: auto;
|
|
||||||
|
|
||||||
.btn + .btn {
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print Button (initially hidden)
|
|
||||||
#printBtn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notice Text
|
|
||||||
.commission-notice {
|
|
||||||
color: #dc3545;
|
|
||||||
margin-top: 30px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voffset3 {
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color Utilities
|
|
||||||
.color_red {
|
|
||||||
color: $accent-orange;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color_blue {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color_darkRed {
|
|
||||||
color: #8b0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table Styles
|
|
||||||
.table-responsive {
|
|
||||||
overflow-x: auto;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: $gray-bg;
|
|
||||||
font-weight: $font-weight-medium;
|
|
||||||
color: $text-dark;
|
|
||||||
padding: $spacing-md;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid $border-gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: $spacing-md;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid $border-gray;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-bordered {
|
|
||||||
border: 1px solid $border-gray;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border: 1px solid $border-gray;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-type {
|
|
||||||
border: 2px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commission Tables
|
|
||||||
.commission-table-wrapper {
|
|
||||||
margin-top: 30px;
|
|
||||||
|
|
||||||
.table {
|
|
||||||
border: 2px solid #dee2e6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary Row
|
|
||||||
.summary-row {
|
|
||||||
background-color: #fff3cd !important;
|
|
||||||
|
|
||||||
td {
|
|
||||||
font-weight: $font-weight-semibold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-amount {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text Alignment Utilities
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-left {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background Utilities
|
|
||||||
.bg-warning {
|
|
||||||
background-color: #fff3cd !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Icon
|
|
||||||
.glyphicon-search::before {
|
|
||||||
content: "🔍";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Commission Print Page ====================
|
|
||||||
|
|
||||||
.info-table {
|
|
||||||
border-top: 10px solid $text-gray;
|
|
||||||
width: 100%;
|
|
||||||
border-bottom: 2px solid $text-dark;
|
|
||||||
margin-bottom: $spacing-2xl;
|
|
||||||
|
|
||||||
th {
|
|
||||||
text-align: left;
|
|
||||||
padding: $spacing-md $spacing-lg;
|
|
||||||
font-weight: $font-weight-semibold;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: $spacing-md $spacing-lg;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.commission-table {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: $spacing-2xl;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border: 1px solid #999 !important;
|
|
||||||
padding: $spacing-md $spacing-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_ygreen {
|
|
||||||
background-color: aquamarine;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_skyBlue {
|
|
||||||
background-color: skyblue;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg_yellow {
|
|
||||||
background-color: yellow;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print-specific styles
|
|
||||||
@media print {
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
background: $white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBox,
|
|
||||||
.searchInfo,
|
|
||||||
#printButton,
|
|
||||||
#backButton,
|
|
||||||
#printBtn {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@page {
|
|
||||||
size: auto;
|
|
||||||
margin: 0 -0cm;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
margin: 0 0cm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive Design
|
|
||||||
@media screen and (max-width: $breakpoint-md) {
|
|
||||||
.wrap {
|
|
||||||
padding: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBox {
|
|
||||||
padding: $spacing-md;
|
|
||||||
|
|
||||||
&.row-two {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: $spacing-md;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-inline {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
label {
|
|
||||||
min-width: auto;
|
|
||||||
margin-bottom: $spacing-xs;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 조회기간: [년] [월] 한 줄 유지
|
|
||||||
&--period {
|
|
||||||
.period-selects {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.period-unit {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
color: $text-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnBox {
|
|
||||||
margin-left: 0;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-responsive {
|
|
||||||
overflow-x: scroll;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commission Print Page Specific
|
|
||||||
// Note: commissionPrint.html uses body class for scoping
|
|
||||||
body.commission-print-page {
|
|
||||||
font-size: 12px;
|
|
||||||
width: 600px;
|
|
||||||
margin: 90px;
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
|
|
||||||
div {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: $spacing-2xl;
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: 1.6;
|
|
||||||
|
|
||||||
&[style*="float: right"] {
|
|
||||||
margin-top: 25px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print buttons
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #5a67d8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -384,6 +384,11 @@
|
|||||||
background-color: #8c959f;
|
background-color: #8c959f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 검토중 - 주황색 배경
|
||||||
|
&--reviewing {
|
||||||
|
background-color: #e08e0b;
|
||||||
|
}
|
||||||
|
|
||||||
// 종료 - 짙은 회색 배경
|
// 종료 - 짙은 회색 배경
|
||||||
&--closed {
|
&--closed {
|
||||||
background-color: #4a5560;
|
background-color: #4a5560;
|
||||||
|
|||||||
@@ -1,496 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org"
|
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
|
||||||
layout:decorate="~{layout/djbank_title_layout}">
|
|
||||||
<head>
|
|
||||||
<title>수수료 관리</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<section layout:fragment="title">
|
|
||||||
<div class="page-title-banner">
|
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
|
||||||
<h1>기관 수수료 관리</h1>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<th:block layout:fragment="contentFragment">
|
|
||||||
<section class="org-register-container">
|
|
||||||
<div class="searchBox row-two">
|
|
||||||
<span class="form-inline form-inline--period">
|
|
||||||
<label for="forYear">조회기간</label>
|
|
||||||
<span class="period-selects">
|
|
||||||
<select id="forYear" class="form-control">
|
|
||||||
<option value="">- 선택 -</option>
|
|
||||||
</select> <span class="period-unit">년</span>
|
|
||||||
<select id="forMonth" class="form-control">
|
|
||||||
<option value="">- 선택 -</option>
|
|
||||||
<option value="1">1</option>
|
|
||||||
<option value="2">2</option>
|
|
||||||
<option value="3">3</option>
|
|
||||||
<option value="4">4</option>
|
|
||||||
<option value="5">5</option>
|
|
||||||
<option value="6">6</option>
|
|
||||||
<option value="7">7</option>
|
|
||||||
<option value="8">8</option>
|
|
||||||
<option value="9">9</option>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="11">11</option>
|
|
||||||
<option value="12">12</option>
|
|
||||||
</select> <span class="period-unit">월</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="btnBox">
|
|
||||||
<span id="searchedPeriod" class="searched-period" style="display:none; margin-right:10px; font-weight:bold; color:#28a745;"></span>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="loadData()">
|
|
||||||
<span class="fa fa-search"></span><span>조회</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" id="printBtn" class="btn btn-secondary" onclick="printCommission()">
|
|
||||||
<span>청구서 출력</span>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="commission-container">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<p class="commission-notice">
|
|
||||||
이용기관은 매월 1~5일 전월 수수료 조회 가능하며, 10일 확정금액으로 정산됩니다. <br/>
|
|
||||||
금액이상시 담당자 연락 및 조정요청 하시기 바랍니다. (매월 1~5일 수수료 조회, 6~9일 수수료 조정 및 확정, 10일 정산)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- API 수수료 테이블 -->
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-bordered border-type">
|
|
||||||
<colgroup>
|
|
||||||
<col style="width:6%"/>
|
|
||||||
<col style="width:16%"/>
|
|
||||||
<col style="width:8%"/>
|
|
||||||
<col style="width:8%"/>
|
|
||||||
<col style="width:8%"/>
|
|
||||||
<col style="width:8%"/>
|
|
||||||
<col style="width:8%"/>
|
|
||||||
<col style="width:8%"/>
|
|
||||||
<col style="width:8%"/>
|
|
||||||
<col style="width:8%"/>
|
|
||||||
<col/>
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowspan="2">구분</th>
|
|
||||||
<th rowspan="2">API 명</th>
|
|
||||||
<th colspan="4">조정 전</th>
|
|
||||||
<th colspan="4">조정 후</th>
|
|
||||||
<th rowspan="2">조정사유</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>처리<br/>건수</th>
|
|
||||||
<th>적용<br/>수수료(원)</th>
|
|
||||||
<th>최저<br/>수수료</th>
|
|
||||||
<th>출금<br/>예상금액</th>
|
|
||||||
<th>조정<br/>건수</th>
|
|
||||||
<th>조정<br/>수수료(원)</th>
|
|
||||||
<th>최저<br/>수수료</th>
|
|
||||||
<th>출금<br/>예상금액</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="apiTableBody">
|
|
||||||
<!-- 동적 생성 -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- 판매 수수료 테이블 -->
|
|
||||||
<table class="table table-bordered border-type voffset3">
|
|
||||||
<colgroup>
|
|
||||||
<col style="width: 6%;">
|
|
||||||
<col style="width: 8%;">
|
|
||||||
<col style="width: 8%;">
|
|
||||||
<col style="width: 5%;">
|
|
||||||
<col style="width: 5%;">
|
|
||||||
<col style="width: 7%;">
|
|
||||||
<col style="width: 6%;">
|
|
||||||
<col style="width: 6%;">
|
|
||||||
<col style="width: 7%;">
|
|
||||||
<col style="width: 7%;">
|
|
||||||
<col style="width: 6%;">
|
|
||||||
<col style="width: 6%;">
|
|
||||||
<col style="width: 7%;">
|
|
||||||
<col style="width: 7%;">
|
|
||||||
<col>
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowspan="2">구분</th>
|
|
||||||
<th rowspan="2">과금대상<br/>업무</th>
|
|
||||||
<th rowspan="2">수수료<br/>유형</th>
|
|
||||||
<th rowspan="2">과금<br/>기준</th>
|
|
||||||
<th rowspan="2">값구분</th>
|
|
||||||
<th rowspan="2">판매액</th>
|
|
||||||
<th colspan="4">조정 전</th>
|
|
||||||
<th colspan="4">조정 후</th>
|
|
||||||
<th rowspan="2">조정사유</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>적용<br/>수수료</th>
|
|
||||||
<th>처리<br/>건수</th>
|
|
||||||
<th>최저<br/>수수료</th>
|
|
||||||
<th>출금<br/>예상수수료</th>
|
|
||||||
<th>적용<br/>수수료</th>
|
|
||||||
<th>처리<br/>건수</th>
|
|
||||||
<th>최저<br/>수수료</th>
|
|
||||||
<th>출금<br/>예상수수료</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="saleTableBody">
|
|
||||||
<!-- 동적 생성 -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- 합계 테이블 -->
|
|
||||||
<table class="table table-bordered border-type voffset3">
|
|
||||||
<colgroup>
|
|
||||||
<col/>
|
|
||||||
<col style="width: 13%"/>
|
|
||||||
<col style="width: 13%"/>
|
|
||||||
<col style="width: 13%"/>
|
|
||||||
<col style="width: 13%"/>
|
|
||||||
<col style="width: 13%"/>
|
|
||||||
<col style="width: 13%"/>
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowspan="2">구분</th>
|
|
||||||
<th colspan="3">조정 전</th>
|
|
||||||
<th colspan="3">조정 후</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>판매액</th>
|
|
||||||
<th>처리건수(건)</th>
|
|
||||||
<th>출금예상금액</th>
|
|
||||||
<th>판매액</th>
|
|
||||||
<th>처리건수(건)</th>
|
|
||||||
<th>출금예상금액</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr class="summary-row">
|
|
||||||
<td class="text-center"><strong id="totalTitle">합계</strong></td>
|
|
||||||
<td class="text-right"><strong id="totalSalesAmount1">-</strong></td>
|
|
||||||
<td class="text-right"><strong id="totalCount1">-</strong></td>
|
|
||||||
<td class="text-right"><strong class="summary-amount" id="totalAmount1">-</strong></td>
|
|
||||||
<td class="text-right"><strong id="totalSalesAmount2">-</strong></td>
|
|
||||||
<td class="text-right"><strong id="totalCount2">-</strong></td>
|
|
||||||
<td class="text-right"><strong class="summary-amount" id="totalAmount2">-</strong></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</th:block>
|
|
||||||
|
|
||||||
<th:block layout:fragment="contentScript">
|
|
||||||
<script th:inline="javascript">
|
|
||||||
// 페이지 로드 시 초기화 및 자동 조회
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var noOrgCode = /*[[${noOrgCode}]]*/ false;
|
|
||||||
if (noOrgCode) {
|
|
||||||
customPopups.showAlert('제휴기관코드가 등록되지 않았습니다.<br/>관리자에게 문의하세요.', function() {
|
|
||||||
history.back();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
initializeDateSelects();
|
|
||||||
// 페이지 로딩 후 자동 조회
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
|
|
||||||
function initializeDateSelects() {
|
|
||||||
const now = new Date();
|
|
||||||
const currentYear = now.getFullYear();
|
|
||||||
const currentMonth = now.getMonth() + 1; // 0-indexed
|
|
||||||
|
|
||||||
// 전월 계산
|
|
||||||
let lastMonth = currentMonth - 1;
|
|
||||||
let lastMonthYear = currentYear;
|
|
||||||
if (lastMonth === 0) {
|
|
||||||
lastMonth = 12;
|
|
||||||
lastMonthYear = currentYear - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 연도 옵션 동적 생성 (최근 3년)
|
|
||||||
const yearSelect = document.getElementById('forYear');
|
|
||||||
for (let year = currentYear; year >= currentYear - 2; year--) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = year;
|
|
||||||
option.textContent = year;
|
|
||||||
if (year === lastMonthYear) {
|
|
||||||
option.selected = true;
|
|
||||||
}
|
|
||||||
yearSelect.appendChild(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 월 기본값 설정 (전월)
|
|
||||||
const monthSelect = document.getElementById('forMonth');
|
|
||||||
monthSelect.value = lastMonth.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSearchedPeriod(year, month) {
|
|
||||||
const searchedPeriodEl = document.getElementById('searchedPeriod');
|
|
||||||
searchedPeriodEl.textContent = year + '년 ' + month + '월 조회완료';
|
|
||||||
searchedPeriodEl.style.display = 'inline';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideSearchedPeriod() {
|
|
||||||
const searchedPeriodEl = document.getElementById('searchedPeriod');
|
|
||||||
searchedPeriodEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(num) {
|
|
||||||
return new Intl.NumberFormat('ko-KR').format(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadData() {
|
|
||||||
const year = document.getElementById('forYear').value;
|
|
||||||
const month = document.getElementById('forMonth').value;
|
|
||||||
|
|
||||||
if (!year || !month) {
|
|
||||||
customPopups.showAlert('조회 기간을 선택해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서버에서 데이터 조회 API 호출
|
|
||||||
fetch('/commission', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
year: year,
|
|
||||||
month: month
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
return response.json().then(data => {
|
|
||||||
if (!response.ok) {
|
|
||||||
// 서버에서 반환한 에러 메시지 추출
|
|
||||||
const errorMessage = data.description || data.message || '데이터 조회에 실패했습니다.';
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(data => renderCommissionData(data, year, month))
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
hideSearchedPeriod();
|
|
||||||
customPopups.showAlert(error.message || '데이터 조회 중 오류가 발생했습니다.');
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCommissionData(data, year, month) {
|
|
||||||
// API 수수료 항목 필터링
|
|
||||||
const apiCommissions = data.list.filter(item => item.paymentTargetType === '01');
|
|
||||||
const saleCommissions = data.list.filter(item => item.paymentTargetType !== '01');
|
|
||||||
|
|
||||||
// API 수수료 테이블 렌더링
|
|
||||||
const apiTableBody = document.getElementById('apiTableBody');
|
|
||||||
apiTableBody.innerHTML = '';
|
|
||||||
|
|
||||||
let apiTotal1 = 0;
|
|
||||||
let apiTotal2 = 0;
|
|
||||||
let apiCount1 = 0;
|
|
||||||
let apiCount2 = 0;
|
|
||||||
|
|
||||||
apiCommissions.forEach((item, index) => {
|
|
||||||
if (index === 0) {
|
|
||||||
apiTableBody.innerHTML += `
|
|
||||||
<tr>
|
|
||||||
<td rowspan="${apiCommissions.length}" class="text-center">API<br/>수수료</td>
|
|
||||||
<td class="text-left">${item.paymentTargetName}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.totalCount || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.value || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.minimumAmount || 0)}</td>
|
|
||||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum || 0)}</span></td>
|
|
||||||
<td class="text-right"><strong class="color_blue">${formatNumber(item.totalCount2 || 0)}</strong></td>
|
|
||||||
<td class="text-right">${formatNumber(item.value2 || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.minimumAmount2 || 0)}</td>
|
|
||||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum2 || 0)}</span></td>
|
|
||||||
<td>${item.changeReason2 || ''}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
apiTableBody.innerHTML += `
|
|
||||||
<tr>
|
|
||||||
<td class="text-left">${item.paymentTargetName}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.totalCount || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.value || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.minimumAmount || 0)}</td>
|
|
||||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum || 0)}</span></td>
|
|
||||||
<td class="text-right"><strong class="color_blue">${formatNumber(item.totalCount2 || 0)}</strong></td>
|
|
||||||
<td class="text-right">${formatNumber(item.value2 || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.minimumAmount2 || 0)}</td>
|
|
||||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum2 || 0)}</span></td>
|
|
||||||
<td>${item.changeReason2 || ''}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
apiTotal1 += parseFloat(item.sum || 0);
|
|
||||||
apiTotal2 += parseFloat(item.sum2 || 0);
|
|
||||||
apiCount1 += parseInt(item.totalCount || 0);
|
|
||||||
apiCount2 += parseInt(item.totalCount2 || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// API 소계
|
|
||||||
apiTableBody.innerHTML += `
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" class="text-center">${year}년 ${month}월 API 수수료 소계</td>
|
|
||||||
<td class="text-right">${formatNumber(apiCount1)}</td>
|
|
||||||
<td class="text-right"></td>
|
|
||||||
<td class="text-right"></td>
|
|
||||||
<td class="text-right"><strong class="color_blue">${formatNumber(apiTotal1)}</strong></td>
|
|
||||||
<td class="text-right">${formatNumber(apiCount2)}</td>
|
|
||||||
<td class="text-right"></td>
|
|
||||||
<td class="text-right"></td>
|
|
||||||
<td class="text-right"><strong class="color_blue">${formatNumber(apiTotal2)}</strong></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 판매 수수료 테이블 렌더링
|
|
||||||
const saleTableBody = document.getElementById('saleTableBody');
|
|
||||||
saleTableBody.innerHTML = '';
|
|
||||||
|
|
||||||
let saleTotal1 = 0;
|
|
||||||
let saleTotal2 = 0;
|
|
||||||
let saleCount1 = 0;
|
|
||||||
let saleCount2 = 0;
|
|
||||||
let saleSalesAmount = 0;
|
|
||||||
|
|
||||||
saleCommissions.forEach((item, index) => {
|
|
||||||
const isPercentageFee = item.paymentBaseName === '금액';
|
|
||||||
if (index === 0) {
|
|
||||||
saleTableBody.innerHTML += `
|
|
||||||
<tr>
|
|
||||||
<td rowspan="${saleCommissions.length}" class="text-center">판매<br/>수수료</td>
|
|
||||||
<td class="text-left">${item.paymentTargetName}</td>
|
|
||||||
<td>${item.detailFeeTypeName || ''}</td>
|
|
||||||
<td>${item.paymentBaseName || ''}</td>
|
|
||||||
<td>${item.valueDivisionName || ''}</td>
|
|
||||||
<td class="text-right">${item.totalValue ? formatNumber(item.totalValue) : ''}</td>
|
|
||||||
<td class="text-right">${isPercentageFee ? (parseFloat(item.value || 0) * 100) + '%' : formatNumber(item.value || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.totalCount || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.minimumAmount || 0)}</td>
|
|
||||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum || 0)}</span></td>
|
|
||||||
<td class="text-right">${isPercentageFee ? (parseFloat(item.value2 || 0) * 100) + '%' : formatNumber(item.value2 || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.totalCount2 || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.minimumAmount2 || 0)}</td>
|
|
||||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum2 || 0)}</span></td>
|
|
||||||
<td>${item.changeReason2 || ''}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
saleTableBody.innerHTML += `
|
|
||||||
<tr>
|
|
||||||
<td class="text-left">${item.paymentTargetName}</td>
|
|
||||||
<td>${item.detailFeeTypeName || ''}</td>
|
|
||||||
<td>${item.paymentBaseName || ''}</td>
|
|
||||||
<td>${item.valueDivisionName || ''}</td>
|
|
||||||
<td class="text-right">${item.totalValue ? formatNumber(item.totalValue) : ''}</td>
|
|
||||||
<td class="text-right">${isPercentageFee ? (parseFloat(item.value || 0) * 100) + '%' : formatNumber(item.value || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.totalCount || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.minimumAmount || 0)}</td>
|
|
||||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum || 0)}</span></td>
|
|
||||||
<td class="text-right">${isPercentageFee ? (parseFloat(item.value2 || 0) * 100) + '%' : formatNumber(item.value2 || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.totalCount2 || 0)}</td>
|
|
||||||
<td class="text-right">${formatNumber(item.minimumAmount2 || 0)}</td>
|
|
||||||
<td class="text-right"><span class="color_darkRed">${formatNumber(item.sum2 || 0)}</span></td>
|
|
||||||
<td>${item.changeReason2 || ''}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
saleTotal1 += parseFloat(item.sum || 0);
|
|
||||||
saleTotal2 += parseFloat(item.sum2 || 0);
|
|
||||||
saleCount1 += parseInt(item.totalCount || 0);
|
|
||||||
saleCount2 += parseInt(item.totalCount2 || 0);
|
|
||||||
saleSalesAmount += parseFloat(item.totalValue || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 판매 소계
|
|
||||||
saleTableBody.innerHTML += `
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="text-center">${year}년 ${month}월 판매 수수료 소계</td>
|
|
||||||
<td class="text-right">${formatNumber(saleSalesAmount)}</td>
|
|
||||||
<td class="text-right"></td>
|
|
||||||
<td class="text-right">${formatNumber(saleCount1)}</td>
|
|
||||||
<td></td>
|
|
||||||
<td class="text-right"><strong class="color_blue">${formatNumber(saleTotal1)}</strong></td>
|
|
||||||
<td class="text-right"></td>
|
|
||||||
<td class="text-right">${formatNumber(saleCount2)}</td>
|
|
||||||
<td></td>
|
|
||||||
<td class="text-right"><strong class="color_blue">${formatNumber(saleTotal2)}</strong></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 합계 업데이트
|
|
||||||
document.getElementById('totalTitle').textContent = `${year}년 ${month}월 합계`;
|
|
||||||
document.getElementById('totalSalesAmount1').textContent = formatNumber(saleSalesAmount);
|
|
||||||
document.getElementById('totalCount1').textContent = formatNumber(apiCount1 + saleCount1);
|
|
||||||
document.getElementById('totalAmount1').textContent = formatNumber(apiTotal1 + saleTotal1);
|
|
||||||
document.getElementById('totalSalesAmount2').textContent = formatNumber(saleSalesAmount);
|
|
||||||
document.getElementById('totalCount2').textContent = formatNumber(apiCount2 + saleCount2);
|
|
||||||
document.getElementById('totalAmount2').textContent = formatNumber(apiTotal2 + saleTotal2);
|
|
||||||
|
|
||||||
// 출력 버튼 표시
|
|
||||||
document.getElementById('printBtn').style.display = 'inline-block';
|
|
||||||
|
|
||||||
// 조회 기간 표시
|
|
||||||
showSearchedPeriod(year, month);
|
|
||||||
}
|
|
||||||
|
|
||||||
function printCommission() {
|
|
||||||
const year = document.getElementById('forYear').value;
|
|
||||||
const month = document.getElementById('forMonth').value;
|
|
||||||
|
|
||||||
if (!year || !month) {
|
|
||||||
customPopups.showAlert('조회 기간을 선택해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 먼저 청구서 출력용 API로 데이터 조회하여 에러 여부 확인
|
|
||||||
fetch('/commission/print/check', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
year: year,
|
|
||||||
month: month
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
return response.json().then(data => {
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = data.description || data.message || '데이터 조회에 실패했습니다.';
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
// 에러가 없으면 청구서 출력 페이지 열기
|
|
||||||
const printUrl = '/commission/print/' + year + '/' + month;
|
|
||||||
window.open(printUrl, '_blank', 'width=800,height=900,scrollbars=yes,resizable=yes');
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
customPopups.showAlert(error.message || '청구서 조회 중 오류가 발생했습니다.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</th:block>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>수수료 청구서</title>
|
|
||||||
<!-- <link rel="stylesheet" th:href="@{/content/css/vendor.css}">-->
|
|
||||||
<!-- <link rel="stylesheet" th:href="@{/css/main.css}">-->
|
|
||||||
<style>
|
|
||||||
html,body{font-size:12px;}
|
|
||||||
body{width:600px; margin:90px}
|
|
||||||
table{border-collapse: collapse;}
|
|
||||||
table th, td{padding:5px 10px;}
|
|
||||||
table tr{font-size:12px;}
|
|
||||||
.info-table{ border-top: 10px solid gray; width: 100%; border-bottom: 2px solid #333;}
|
|
||||||
.info-table th{text-align:left;}
|
|
||||||
.commission-table{width:100%;}
|
|
||||||
.commission-table th,.commission-table td{border:1px solid #999 !important}
|
|
||||||
.commission-table td{text-align:right}
|
|
||||||
.bg_ygreen{background-color:aquamarine; text-align:center}
|
|
||||||
.bg_skyBlue{background-color:skyblue; text-align:center}
|
|
||||||
.bg_yellow{background-color:yellow; text-align:center}
|
|
||||||
|
|
||||||
DIV { position: relative; }
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #5a67d8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
@page {
|
|
||||||
size: auto;
|
|
||||||
margin: 0 -0cm;
|
|
||||||
}
|
|
||||||
html {
|
|
||||||
margin: 0 0cm;
|
|
||||||
}
|
|
||||||
.control {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="commission-print-page">
|
|
||||||
<div class="control">
|
|
||||||
<input id="printButton" type="button" class="btn btn-primary" value="청구서 인쇄" onclick="window.print()"/>
|
|
||||||
</div>
|
|
||||||
<div id="PrintDIV">
|
|
||||||
<h1 style="text-align:center">
|
|
||||||
<img style="height:30px;" th:src="@{/img/logo/logo-djb.png}" alt="DJBank 오픈뱅킹" />
|
|
||||||
</h1>
|
|
||||||
<p style="text-align:center">우61470 광주광역시 동구 제봉로 225 전화 (062)239-6722 FAX (062)239-6719 오픈뱅크 플랫폼 담당자</p>
|
|
||||||
|
|
||||||
<table class="info-table">
|
|
||||||
<colgroup>
|
|
||||||
<col style="width:15%;" />
|
|
||||||
<col/>
|
|
||||||
</colgroup>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th>문서번호</th>
|
|
||||||
<td><span th:text="|전자금융(OBP) ${year}-${month}-001호|">전자금융(OBP) 2024-03-001호</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>발 급 일</th>
|
|
||||||
<td><span th:text="${toDay}">2024년 3월 15일</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>수 신</th>
|
|
||||||
<td><span th:text="${organization}">ABC 핀테크 주식회사</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>참 조</th>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>제 목</th>
|
|
||||||
<td><span th:text="|${year}년 ${month}월 오픈뱅크플랫폼 API 서비스 이용수수료 청구|">2024년 3월 오픈뱅크플랫폼 API 서비스 이용수수료 청구</span></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p>1. 귀 사의 무궁한 발전을 기원합니다.</p>
|
|
||||||
<p>2. 귀 사와 체결된 서비스 이용 계약에 따라 동 업무처리와 관련하여 발생된 수수료를 다음과 같이 청구하오니 <br/>협조하여 주시기 바랍니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 style="float:left">1. 청구내역</h2>
|
|
||||||
<p style="float: right;margin-top: 25px;">(단위:건수/원)</p>
|
|
||||||
|
|
||||||
<table class="commission-table">
|
|
||||||
<colgroup>
|
|
||||||
<col style="width:35%"/>
|
|
||||||
<col style="width:20%"/>
|
|
||||||
<col style="width:15%"/>
|
|
||||||
<col style="width:15%"/>
|
|
||||||
<col style="width:15%"/>
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="bg_ygreen">청구내용</th>
|
|
||||||
<th class="bg_ygreen">대출금액</th>
|
|
||||||
<th class="bg_ygreen">처리건수</th>
|
|
||||||
<th class="bg_ygreen">수수료</th>
|
|
||||||
<th class="bg_ygreen">수수료합계</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<th:block th:if="${commission != null and commission.list != null}">
|
|
||||||
<!-- API 수수료 -->
|
|
||||||
<tr th:each="item : ${commission.list}" th:if="${item.paymentTargetType == '01'}">
|
|
||||||
<td style="text-align:left" th:text="${item.paymentTargetName}">계좌잔액조회</td>
|
|
||||||
<td></td>
|
|
||||||
<td th:text="${#numbers.formatInteger(item.totalValue2, 0, 'COMMA')}">20,543</td>
|
|
||||||
<td th:text="|₩${#numbers.formatInteger(item.value2, 0, 'COMMA')}|">₩50</td>
|
|
||||||
<td th:text="|₩${#numbers.formatInteger(item.sum2, 0, 'COMMA')}|">₩1,027,150</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- API 소계 -->
|
|
||||||
<th:block th:if="${commission.apiSum2 != null}">
|
|
||||||
<tr>
|
|
||||||
<th class="bg_skyBlue" colspan="4">소계</th>
|
|
||||||
<td class="bg_skyBlue" th:text="|₩${#numbers.formatInteger(commission.apiSum2, 0, 'COMMA')}|">₩3,306,780</td>
|
|
||||||
</tr>
|
|
||||||
</th:block>
|
|
||||||
|
|
||||||
<!-- 판매 수수료 -->
|
|
||||||
<tr th:each="item : ${commission.list}" th:if="${item.paymentTargetType != '01'}">
|
|
||||||
<td style="text-align:left" th:text="${item.paymentTargetName}">개인대출 상품 A</td>
|
|
||||||
<td th:text="${item.paymentBaseName == '금액' ? '₩' + #numbers.formatInteger(item.totalValue2, 0, 'COMMA') : ''}">₩1,050,000,000</td>
|
|
||||||
<td th:text="${item.paymentBaseName == '건' ? #numbers.formatInteger(item.totalCount2, 0, 'COMMA') : ''}">55</td>
|
|
||||||
<td th:text="|₩${#numbers.formatInteger(item.value2, 0, 'COMMA')}|">₩5,000</td>
|
|
||||||
<td th:text="|₩${#numbers.formatInteger(item.sum2, 0, 'COMMA')}|">₩275,000</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- 판매 소계 -->
|
|
||||||
<th:block th:if="${commission.productSum2 != null}">
|
|
||||||
<tr>
|
|
||||||
<th class="bg_skyBlue" colspan="4">소계</th>
|
|
||||||
<td class="bg_skyBlue" th:text="|₩${#numbers.formatInteger(commission.productSum2, 0, 'COMMA')}|">₩533,000</td>
|
|
||||||
</tr>
|
|
||||||
</th:block>
|
|
||||||
|
|
||||||
<!-- 합계 -->
|
|
||||||
<th:block th:if="${commission.sum2 != null}">
|
|
||||||
<tr>
|
|
||||||
<th class="bg_yellow" colspan="4">합계</th>
|
|
||||||
<td class="bg_yellow" th:text="|₩${#numbers.formatInteger(commission.sum2, 0, 'COMMA')}|">₩3,839,780</td>
|
|
||||||
</tr>
|
|
||||||
</th:block>
|
|
||||||
</th:block>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p>* 수수료는 Max(처리건수*금액, 월최저수수료) 금액으로 출금됩니다</p>
|
|
||||||
<br>
|
|
||||||
<h2>2. 수수료정산</h2>
|
|
||||||
<p>① 정산방법 : 수수료 결제계좌에서 자동출금</p>
|
|
||||||
<p>② 처리일자 : 매월10일 (휴,공휴일인 경우 익영업일). 끝.</p>
|
|
||||||
|
|
||||||
<div style="text-align:center;padding:20px 0">
|
|
||||||
<h1>
|
|
||||||
<span style="letter-spacing:0.8;">주식회사 광주은행</span><br/>
|
|
||||||
<span style="letter-spacing:6.5;">디지털</span> <span style="letter-spacing:6.5;">사업부장</span>
|
|
||||||
<small style="position: absolute; bottom: 39px; margin-left: 12px; font-weight: normal; font-size: 13px;">(직인생략)</small>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script th:if="${error}" th:inline="javascript">
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var errorMessage = /*[[${error}]]*/ '';
|
|
||||||
if (errorMessage) {
|
|
||||||
alert(errorMessage);
|
|
||||||
// 에러 발생 시 인쇄 버튼 비활성화
|
|
||||||
var printButton = document.getElementById('printButton');
|
|
||||||
if (printButton) {
|
|
||||||
printButton.disabled = true;
|
|
||||||
printButton.value = '조회 오류';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
||||||
|
<h1>FAQ</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section layout:fragment="contentFragment">
|
||||||
|
<div class="notice-detail-container">
|
||||||
|
|
||||||
|
<!-- FAQ Header: Question and Date -->
|
||||||
|
<div class="notice-detail-header">
|
||||||
|
<h2 class="notice-detail-title">
|
||||||
|
<span th:text="${faq.faqQuestion}">FAQ 질문</span>
|
||||||
|
</h2>
|
||||||
|
<span class="notice-detail-date" th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachment Section -->
|
||||||
|
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(faq.fileId)}">
|
||||||
|
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(faq.fileId)}">
|
||||||
|
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
|
||||||
|
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<span class="attachment-label">첨부파일</span>
|
||||||
|
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Section -->
|
||||||
|
<div class="notice-detail-content">
|
||||||
|
<div id="faqDetail" class="notice-content-body editor-content" th:utext="${faq.faqAnswer}">
|
||||||
|
답변 내용이 여기에 표시됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="notice-detail-actions">
|
||||||
|
<button type="button" class="btn-notice-list" th:onclick="|location.href='@{/faq_list}'|">목록</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentScript">
|
||||||
|
<script>
|
||||||
|
function fn_downloadFile(fileId, fileSn) {
|
||||||
|
window.open('[[@{/file/download}]]' + "?fileId=" + fileId + "&fileSn=" + fileSn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHTMLEntities(text) {
|
||||||
|
var textArea = document.createElement('textarea');
|
||||||
|
textArea.innerHTML = text;
|
||||||
|
return textArea.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var element = document.getElementById('faqDetail');
|
||||||
|
if (element) {
|
||||||
|
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<section class="user-management-container">
|
<section class="user-management-container notice-list-container">
|
||||||
|
|
||||||
<!-- Search and Filter Controls -->
|
<!-- Search and Filter Controls -->
|
||||||
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
|
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
|
||||||
@@ -38,21 +38,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- FAQ Accordion -->
|
<!-- FAQ Table -->
|
||||||
<div class="faq-accordion" th:if="${!#lists.isEmpty(page.content)}">
|
<div class="list-table" th:if="${!#lists.isEmpty(page.content)}">
|
||||||
<div class="faq-item" th:each="faq : ${page.content}">
|
<!-- Table Header -->
|
||||||
<div class="faq-question">
|
<div class="list-table-header">
|
||||||
<span class="faq-question-text" th:text="${faq.faqQuestion}">질문 내용이 여기에 표시됩니다.</span>
|
<div class="header-cell" style="width: 80px;">NO</div>
|
||||||
<span class="faq-icon">
|
<div class="header-cell" style="flex: 1; min-width: 200px;">질문</div>
|
||||||
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||||
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
</div>
|
||||||
</svg>
|
|
||||||
</span>
|
<!-- Table Body -->
|
||||||
</div>
|
<div class="list-table-body">
|
||||||
<div class="faq-answer">
|
<div class="list-table-row"
|
||||||
<div class="faq-answer-content editor-content" th:utext="${faq.faqAnswer}">
|
th:each="faq, status : ${page.content}"
|
||||||
답변 내용이 여기에 표시됩니다.
|
th:onclick="'location.href=\'' + @{/faq_view(id=${faq.id})} + '\''"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
|
||||||
|
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
||||||
|
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="질문">
|
||||||
|
<a th:href="@{/faq_view(id=${faq.id})}" class="notice-title-link">
|
||||||
|
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||||
|
<span th:text="${faq.faqQuestion}">FAQ 질문</span>
|
||||||
|
<span class="file-icon" th:if="${faq.fileId != null and !faq.fileId.isEmpty()}">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row-cell" style="width: 120px;" data-label="등록일"
|
||||||
|
th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,37 +110,6 @@
|
|||||||
document.faqForm.page.value = 1;
|
document.faqForm.page.value = 1;
|
||||||
document.faqForm.submit();
|
document.faqForm.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeHTMLEntities(text) {
|
|
||||||
var textArea = document.createElement('textarea');
|
|
||||||
textArea.innerHTML = text;
|
|
||||||
return textArea.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Decode HTML entities in FAQ answers
|
|
||||||
document.querySelectorAll('.faq-answer-content').forEach(function(element) {
|
|
||||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
|
||||||
});
|
|
||||||
|
|
||||||
// FAQ Accordion functionality
|
|
||||||
document.querySelectorAll('.faq-question').forEach(function(question) {
|
|
||||||
question.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const faqItem = this.closest('.faq-item');
|
|
||||||
const isActive = faqItem.classList.contains('active');
|
|
||||||
|
|
||||||
// Close all other items (optional - for single open behavior)
|
|
||||||
// document.querySelectorAll('.faq-item.active').forEach(function(item) {
|
|
||||||
// item.classList.remove('active');
|
|
||||||
// });
|
|
||||||
|
|
||||||
// Toggle active state
|
|
||||||
faqItem.classList.toggle('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<div class="inquiry-detail-header">
|
<div class="inquiry-detail-header">
|
||||||
<div class="inquiry-header-top">
|
<div class="inquiry-header-top">
|
||||||
<span class="inquiry-status-badge"
|
<span class="inquiry-status-badge"
|
||||||
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
|
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
||||||
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
|
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
||||||
답변대기
|
답변대기
|
||||||
</span>
|
</span>
|
||||||
<div class="inquiry-header-right">
|
<div class="inquiry-header-right">
|
||||||
|
|||||||
@@ -70,8 +70,8 @@
|
|||||||
<div class="row-cell" style="width: 100px;" data-label="작성자" th:text="${inquiry.maskedInquirerName}">홍**</div>
|
<div class="row-cell" style="width: 100px;" data-label="작성자" th:text="${inquiry.maskedInquirerName}">홍**</div>
|
||||||
<div class="row-cell" style="width: 120px;" data-label="처리상태">
|
<div class="row-cell" style="width: 120px;" data-label="처리상태">
|
||||||
<span class="inquiry-status-badge"
|
<span class="inquiry-status-badge"
|
||||||
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
|
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
||||||
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
|
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
||||||
답변대기
|
답변대기
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,6 +157,27 @@
|
|||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
|
|
||||||
|
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
|
||||||
|
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
|
||||||
|
// 단, 입력 중에는 이탈 금지 → idle 타이머(활동 시 리셋, 입력값/포커스 있으면 보류).
|
||||||
|
// 만료 시간은 하드코딩하지 않고 서버 세션 타임아웃(초)을 렌더 시점에 읽어 파생 → yml 변경 시 자동 반영.
|
||||||
|
var SESSION_TIMEOUT_SEC = /*[[${#request.session.maxInactiveInterval}]]*/ 600;
|
||||||
|
var LOGIN_IDLE_LIMIT_MS = Math.max(60, SESSION_TIMEOUT_SEC - 120) * 1000; // 세션 만료 2분 전
|
||||||
|
var loginIdleTimer;
|
||||||
|
function scheduleIdleRedirect() {
|
||||||
|
clearTimeout(loginIdleTimer);
|
||||||
|
loginIdleTimer = setTimeout(function () {
|
||||||
|
var idEl = document.getElementById('id');
|
||||||
|
var pwEl = document.getElementById('password');
|
||||||
|
var busy = document.activeElement === idEl || document.activeElement === pwEl
|
||||||
|
|| (idEl && idEl.value) || (pwEl && pwEl.value);
|
||||||
|
if (busy) { scheduleIdleRedirect(); return; } // 입력 중/입력값 있음 → 이탈 보류
|
||||||
|
window.location.href = /*[[@{/}]]*/ '/';
|
||||||
|
}, LOGIN_IDLE_LIMIT_MS);
|
||||||
|
}
|
||||||
|
$('#id, #password, #checkId').on('input keydown focus click', scheduleIdleRedirect);
|
||||||
|
scheduleIdleRedirect();
|
||||||
|
|
||||||
var successMsg = [[${success}]];
|
var successMsg = [[${success}]];
|
||||||
console.log("Success message:", successMsg);
|
console.log("Success message:", successMsg);
|
||||||
if (successMsg) {
|
if (successMsg) {
|
||||||
|
|||||||
@@ -59,6 +59,14 @@
|
|||||||
<!-- Credential Info Section -->
|
<!-- Credential Info Section -->
|
||||||
<div class="apikey-info-section">
|
<div class="apikey-info-section">
|
||||||
|
|
||||||
|
<!-- 인증 방식 -->
|
||||||
|
<div class="info-row">
|
||||||
|
<label class="info-label">인증 방식</label>
|
||||||
|
<div class="info-value">
|
||||||
|
<div class="info-value-box" th:text="${authType}">OAuth2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Client ID -->
|
<!-- Client ID -->
|
||||||
<div class="info-row info-row-with-action">
|
<div class="info-row info-row-with-action">
|
||||||
<label class="info-label">Client ID</label>
|
<label class="info-label">Client ID</label>
|
||||||
@@ -72,27 +80,47 @@
|
|||||||
|
|
||||||
<!-- Client Secret -->
|
<!-- Client Secret -->
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<label class="info-label">Client Secret</label>
|
<label class="info-label">
|
||||||
|
Client Secret
|
||||||
|
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내" aria-label="비밀정보 안내"
|
||||||
|
style="margin-left:6px;width:18px;height:18px;border-radius:50%;border:1px solid #bbb;background:#f5f5f5;color:#666;font-size:12px;line-height:1;cursor:pointer;padding:0;vertical-align:middle;">?</button>
|
||||||
|
</label>
|
||||||
<div class="info-value">
|
<div class="info-value">
|
||||||
<!-- View Button (shown initially) -->
|
|
||||||
<button type="button" class="btn-view-secret" id="hiddenSecretBox" onclick="showPasswordPrompt()">
|
<!-- secret이 아직 DB에 존재: 최초 1회 조회 가능 -->
|
||||||
<span>조회</span>
|
<th:block th:if="${secretAvailable}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<!-- View Button (shown initially) -->
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<button type="button" class="btn-view-secret" id="hiddenSecretBox" onclick="showPasswordPrompt()">
|
||||||
<path d="m21 21-4.35-4.35"></path>
|
<span>조회</span>
|
||||||
</svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
</button>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<!-- Actual Secret (hidden initially) -->
|
<path d="m21 21-4.35-4.35"></path>
|
||||||
<div id="revealedSecretBox" style="display: none;">
|
</svg>
|
||||||
<div class="info-row info-row-with-action" style="margin-bottom: 0;">
|
</button>
|
||||||
<div class="info-value" style="display: flex; gap: 16px; align-items: center;">
|
<!-- Actual Secret (hidden initially, 값은 서버 응답으로 JS가 주입) -->
|
||||||
<div class="info-value-box with-copy" th:text="${apiKey.clientsecret}">secret-key-67890</div>
|
<div id="revealedSecretBox" style="display: none;">
|
||||||
<button type="button" class="btn-copy-action" th:data-secret="${apiKey.clientsecret}" onclick="copyToClipboardFromButton(this)">
|
<div class="info-row info-row-with-action" style="margin-bottom: 0;">
|
||||||
+ 복사
|
<div class="info-value" style="display: flex; gap: 16px; align-items: center;">
|
||||||
</button>
|
<div class="info-value-box with-copy" id="revealedSecretValue"></div>
|
||||||
|
<button type="button" class="btn-copy-action" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)">
|
||||||
|
+ 복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="secret-warning" style="margin:10px 0 0;padding:10px 12px;background:#fdecea;border:1px solid #f5c6c2;border-radius:6px;color:#c0392b;font-size:13px;line-height:1.6;">
|
||||||
|
⚠ 이 값은 <strong>지금 한 번만</strong> 표시되며, 이후에는 다시 조회할 수 없습니다.<br>
|
||||||
|
반드시 <strong>안전한 곳에 복사하여 보관</strong>해 주세요.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!-- secret이 이미 노출·삭제됨 -->
|
||||||
|
<div th:unless="${secretAvailable}" class="secret-revealed-notice" style="display:flex;align-items:center;gap:12px;">
|
||||||
|
<span class="secret-revealed-text" style="color:#888;font-size:14px;">이미 1회 노출되어 삭제된 인증정보입니다.</span>
|
||||||
|
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()"
|
||||||
|
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실 시 안내</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,36 +206,50 @@
|
|||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
/*<![CDATA[*/
|
/*<![CDATA[*/
|
||||||
|
|
||||||
// Show password prompt for viewing client secret
|
var CREDENTIAL_CLIENT_ID = /*[[${apiKey.clientid}]]*/ '';
|
||||||
|
|
||||||
|
// 비밀정보 안내(분실 시 조치) 가이드 팝업
|
||||||
|
function showLostKeyGuide() {
|
||||||
|
customPopups.showAlert(
|
||||||
|
'Client Secret은 보안을 위해 <strong>최초 1회만 조회</strong>할 수 있으며,<br>' +
|
||||||
|
'조회하는 즉시 개발자포탈에서 <strong>영구 삭제</strong>됩니다.<br><br>' +
|
||||||
|
'값을 분실하셨다면 복구가 불가능하므로,<br>' +
|
||||||
|
'<strong>인증키를 새로 신청</strong>하여 발급받아 주세요.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
|
||||||
function showPasswordPrompt() {
|
function showPasswordPrompt() {
|
||||||
customPopups.showPasswordInput({
|
customPopups.showPasswordInput({
|
||||||
title: 'Client Secret 조회',
|
title: 'Client Secret 조회',
|
||||||
message: '보안을 위해 비밀번호를 입력해주세요.',
|
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
|
||||||
onConfirm: function(password) {
|
onConfirm: function(password) {
|
||||||
// Verify password via AJAX
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: /*[[@{/myapikey/verify-password}]]*/ '/myapikey/verify-password',
|
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {password: password},
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
|
||||||
headers: {
|
headers: {
|
||||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||||
},
|
|
||||||
success: function(response) {
|
|
||||||
if (response.success) {
|
|
||||||
// Hide password popup
|
|
||||||
customPopups.hidePasswordInput();
|
|
||||||
|
|
||||||
// Reveal the client secret
|
|
||||||
$('#hiddenSecretBox').hide();
|
|
||||||
$('#revealedSecretBox').fadeIn(300);
|
|
||||||
} else {
|
|
||||||
// Show error message
|
|
||||||
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function() {
|
|
||||||
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
|
||||||
}
|
}
|
||||||
|
}).done(function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
customPopups.hidePasswordInput();
|
||||||
|
|
||||||
|
// 서버가 반환한 secret을 화면에 1회 주입
|
||||||
|
$('#revealedSecretValue').text(response.secret);
|
||||||
|
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
||||||
|
|
||||||
|
$('#hiddenSecretBox').hide();
|
||||||
|
$('#revealedSecretBox').fadeIn(300);
|
||||||
|
} else if (response.alreadyRevealed) {
|
||||||
|
customPopups.hidePasswordInput();
|
||||||
|
showLostKeyGuide();
|
||||||
|
} else {
|
||||||
|
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
||||||
|
}
|
||||||
|
}).fail(function() {
|
||||||
|
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onCancel: function() {
|
onCancel: function() {
|
||||||
@@ -252,31 +294,38 @@
|
|||||||
function deleteApiKeyFromButton(button) {
|
function deleteApiKeyFromButton(button) {
|
||||||
var clientId = $(button).data('client-id');
|
var clientId = $(button).data('client-id');
|
||||||
|
|
||||||
if (!confirm('정말로 이 인증키를 삭제하시겠습니까?')) {
|
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function(confirmed) {
|
||||||
return;
|
if (!confirmed) {
|
||||||
}
|
return;
|
||||||
|
|
||||||
$('.loading-overlay').show();
|
|
||||||
|
|
||||||
const requestData = {
|
|
||||||
clientId: clientId
|
|
||||||
};
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: '/myapikey/api_key_delete',
|
|
||||||
type: 'POST',
|
|
||||||
contentType: 'application/json',
|
|
||||||
data: JSON.stringify(requestData),
|
|
||||||
headers: {
|
|
||||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
|
||||||
}
|
}
|
||||||
}).done(function(response) {
|
|
||||||
alert(response.msg || 'API Key가 삭제되었습니다.');
|
$('.loading-overlay').show();
|
||||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
|
||||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
const requestData = {
|
||||||
alert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
clientId: clientId
|
||||||
}).always(function() {
|
};
|
||||||
$('.loading-overlay').hide();
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/myapikey/api_key_delete',
|
||||||
|
type: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify(requestData),
|
||||||
|
headers: {
|
||||||
|
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||||
|
}
|
||||||
|
}).done(function(response) {
|
||||||
|
if (response && response.success === false) {
|
||||||
|
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function() {
|
||||||
|
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||||
|
});
|
||||||
|
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||||
|
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||||
|
}).always(function() {
|
||||||
|
$('.loading-overlay').hide();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2" marker-end="url(#o2leg-arrow-gray)"/>
|
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2" marker-end="url(#o2leg-arrow-gray)"/>
|
||||||
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500" fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope, jti }</text>
|
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500" fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope, jti }</text>
|
||||||
|
|
||||||
<text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">③ GET /api/v1/... · Authorization: Bearer <access_token></text>
|
<text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">③ GET /api/v1/... · X-AUTH-TOKEN: Bearer <access_token></text>
|
||||||
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2" marker-end="url(#o2leg-arrow-primary)"/>
|
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2" marker-end="url(#o2leg-arrow-primary)"/>
|
||||||
|
|
||||||
<text x="560" y="248" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">④ 검증 후 API 응답</text>
|
<text x="560" y="248" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">④ 검증 후 API 응답</text>
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<p class="oauth2-2legged__desc">ClientID/Secret 으로 토큰 엔드포인트에 POST 합니다.</p>
|
<p class="oauth2-2legged__desc">ClientID/Secret 으로 토큰 엔드포인트에 POST 합니다.</p>
|
||||||
|
|
||||||
<div class="oauth2-2legged__endpoint-box">
|
<div class="oauth2-2legged__endpoint-box">
|
||||||
<span class="oauth2-2legged__method">POST</span>
|
<span class="oauth2-2legged__method">GET · POST</span>
|
||||||
<code class="oauth2-2legged__endpoint-path">https://openapi.djbank.co.kr/dj/oauth/token</code>
|
<code class="oauth2-2legged__endpoint-path">https://openapi.djbank.co.kr/dj/oauth/token</code>
|
||||||
<span class="oauth2-2legged__endpoint-content-type">x-www-form-urlencoded</span>
|
<span class="oauth2-2legged__endpoint-content-type">x-www-form-urlencoded</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,8 +173,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>scope</code></td>
|
<td><code>scope</code></td>
|
||||||
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--optional">선택</span></td>
|
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span></td>
|
||||||
<td>호출 권한 범위 (공백 구분)</td>
|
<td>고정값 "api"</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -189,7 +189,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
-d <span class="o2leg-c">'grant_type=client_credentials'</span> \
|
-d <span class="o2leg-c">'grant_type=client_credentials'</span> \
|
||||||
-d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \
|
-d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \
|
||||||
-d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \
|
-d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \
|
||||||
-d <span class="o2leg-c">'scope=read.accounts'</span>
|
-d <span class="o2leg-c">'scope=api'</span>
|
||||||
|
|
||||||
<span class="o2leg-g"># 응답: 200 OK + JSON</span></pre>
|
<span class="o2leg-g"># 응답: 200 OK + JSON</span></pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,8 +208,8 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<pre class="oauth2-2legged__code-block">{
|
<pre class="oauth2-2legged__code-block">{
|
||||||
<span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>,
|
<span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>,
|
||||||
<span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>,
|
<span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>,
|
||||||
<span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86400</span>,
|
<span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86399</span>,
|
||||||
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"read.accounts"</span>,
|
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"api"</span>,
|
||||||
<span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span>
|
<span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<tr>
|
<tr>
|
||||||
<td><code class="oauth2-2legged__field-name">scope</code></td>
|
<td><code class="oauth2-2legged__field-name">scope</code></td>
|
||||||
<td>string</td>
|
<td>string</td>
|
||||||
<td>실제 부여된 권한 범위</td>
|
<td>부여된 권한 범위 (api)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code class="oauth2-2legged__field-name">jti</code></td>
|
<td><code class="oauth2-2legged__field-name">jti</code></td>
|
||||||
@@ -262,13 +262,13 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title">
|
<section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title">
|
||||||
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
||||||
<h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2>
|
<h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2>
|
||||||
<p class="oauth2-2legged__desc">Authorization 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p>
|
<p class="oauth2-2legged__desc">X-AUTH-TOKEN 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p>
|
||||||
|
|
||||||
<div class="oauth2-2legged__step-grid">
|
<div class="oauth2-2legged__step-grid">
|
||||||
<div class="oauth2-2legged__panel">
|
<div class="oauth2-2legged__panel">
|
||||||
<h3 class="oauth2-2legged__panel-title">필수 헤더</h3>
|
<h3 class="oauth2-2legged__panel-title">필수 헤더</h3>
|
||||||
<div class="oauth2-2legged__header-box">
|
<div class="oauth2-2legged__header-box">
|
||||||
<code class="oauth2-2legged__header-key">Authorization:</code>
|
<code class="oauth2-2legged__header-key">X-AUTH-TOKEN:</code>
|
||||||
<code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code>
|
<code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li>
|
<li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li>
|
||||||
<li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li>
|
<li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li>
|
||||||
<li>매 호출마다 토큰을 새로 발급하지 마세요</li>
|
<li>매 호출마다 토큰을 새로 발급하지 마세요</li>
|
||||||
<li>권한이 다른 API 는 scope 별로 토큰을 분리 발급</li>
|
<li>401 "Access token expired" 응답 시 재발급 후 재호출</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span>
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span>
|
||||||
curl -X <span class="o2leg-y">GET</span> \
|
curl -X <span class="o2leg-y">GET</span> \
|
||||||
<span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \
|
<span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \
|
||||||
-H <span class="o2leg-c">'Authorization: Bearer eyJhbGciOiJSUzI..'</span> \
|
-H <span class="o2leg-c">'X-AUTH-TOKEN: Bearer eyJhbGciOiJSUzI..'</span> \
|
||||||
-H <span class="o2leg-c">'Accept: application/json'</span>
|
-H <span class="o2leg-c">'Accept: application/json'</span>
|
||||||
|
|
||||||
<span class="o2leg-g"># 응답</span>
|
<span class="o2leg-g"># 응답</span>
|
||||||
@@ -313,28 +313,52 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">400</span></td>
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td>
|
||||||
<td><code>invalid_request</code></td>
|
<td><code>E.GW.AUTH_FAIL</code></td>
|
||||||
<td>필수 파라미터 누락/형식 오류</td>
|
<td>client not found (client_id 오류)</td>
|
||||||
<td>grant_type, client_id, client_secret 확인</td>
|
<td>client_id 확인</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td>
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td>
|
||||||
<td><code>invalid_client</code></td>
|
<td><code>E.GW.AUTH_FAIL</code></td>
|
||||||
<td>자격증명 불일치 / 미승인</td>
|
<td>Bad client credentials (client_secret 불일치)</td>
|
||||||
<td>ClientID·Secret 재확인, 앱 상태 점검</td>
|
<td>client_secret 확인</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td>
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td>
|
||||||
<td><code>invalid_token</code></td>
|
<td><code>E.GW.AUTH_FAIL</code></td>
|
||||||
<td>만료/위·변조된 토큰</td>
|
<td>Include unacceptable scope (허용되지 않은 scope)</td>
|
||||||
|
<td>scope 를 api 로 설정</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">401</span></td>
|
||||||
|
<td><code>E.GW.AUTH_FAIL</code></td>
|
||||||
|
<td>Access token expired (토큰 만료)</td>
|
||||||
<td>토큰 재발급 후 재시도</td>
|
<td>토큰 재발급 후 재시도</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">403</span></td>
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">400</span></td>
|
||||||
<td><code>insufficient_scope</code></td>
|
<td><code>E.GW.AUTH_FAIL</code></td>
|
||||||
<td>scope 권한 부족</td>
|
<td>Invalid Mandatory Field {grant_type} (누락)</td>
|
||||||
<td>필요 scope 추가 신청 후 재발급</td>
|
<td>grant_type 포함 여부 확인</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">400</span></td>
|
||||||
|
<td><code>E.GW.AUTH_FAIL</code></td>
|
||||||
|
<td>Invalid Mandatory Field {client_id} (누락)</td>
|
||||||
|
<td>client_id 포함 여부 확인</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">400</span></td>
|
||||||
|
<td><code>E.GW.AUTH_FAIL</code></td>
|
||||||
|
<td>Invalid Mandatory Field {client_secret} (누락)</td>
|
||||||
|
<td>client_secret 포함 여부 확인</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">400</span></td>
|
||||||
|
<td><code>E.GW.AUTH_FAIL</code></td>
|
||||||
|
<td>Invalid Mandatory Field {scope} (누락)</td>
|
||||||
|
<td>scope=api 포함 여부 확인</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -351,11 +375,9 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
|
|||||||
<div class="oauth2-2legged__code-panel oauth2-2legged__code-panel--error">
|
<div class="oauth2-2legged__code-panel oauth2-2legged__code-panel--error">
|
||||||
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--error">401 Unauthorized · JSON</span>
|
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--error">401 Unauthorized · JSON</span>
|
||||||
<pre class="oauth2-2legged__code-block">{
|
<pre class="oauth2-2legged__code-block">{
|
||||||
<span class="o2leg-c">"error"</span>: <span class="o2leg-y">"invalid_client"</span>,
|
<span class="o2leg-c">"error"</span>: <span class="o2leg-y">"E.GW.AUTH_FAIL"</span>,
|
||||||
<span class="o2leg-c">"error_description"</span>:
|
<span class="o2leg-c">"error_description"</span>:
|
||||||
<span class="o2leg-y">"Client authentication failed"</span>,
|
<span class="o2leg-y">"Unauthorized. [Bad client credentials(Client Secret is not match)]"</span>
|
||||||
<span class="o2leg-c">"status"</span>: <span class="o2leg-p">401</span>,
|
|
||||||
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-y">"2026-06-10T11:23:45Z"</span>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<span class="o2leg-g"># 처리: ClientID·Secret 확인 후 재시도</span></pre>
|
<span class="o2leg-g"># 처리: ClientID·Secret 확인 후 재시도</span></pre>
|
||||||
@@ -375,26 +397,16 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
|
|||||||
<tr>
|
<tr>
|
||||||
<td><code class="oauth2-2legged__field-name">error</code></td>
|
<td><code class="oauth2-2legged__field-name">error</code></td>
|
||||||
<td>string</td>
|
<td>string</td>
|
||||||
<td>에러 코드 (예: invalid_client)</td>
|
<td>에러 코드 (예: E.GW.AUTH_FAIL)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code class="oauth2-2legged__field-name">error_description</code></td>
|
<td><code class="oauth2-2legged__field-name">error_description</code></td>
|
||||||
<td>string</td>
|
<td>string</td>
|
||||||
<td>사람이 읽을 수 있는 설명</td>
|
<td>오류 상세 설명 (사람이 읽을 수 있는 형태)</td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code class="oauth2-2legged__field-name">status</code></td>
|
|
||||||
<td>int</td>
|
|
||||||
<td>HTTP 상태 코드</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code class="oauth2-2legged__field-name">timestamp</code></td>
|
|
||||||
<td>string</td>
|
|
||||||
<td>에러 발생 시각 (ISO 8601)</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="oauth2-2legged__error-note">⚠ error 코드는 RFC 6749 표준 코드 또는 DJBank 확장 코드</p>
|
<p class="oauth2-2legged__error-note">⚠ error 코드는 게이트웨이 표준 코드 (E.GW.AUTH_FAIL)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -7,6 +7,23 @@
|
|||||||
<meta content="https://www.eactive.co.kr/" property="og:url"/>
|
<meta content="https://www.eactive.co.kr/" property="og:url"/>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
|
|
||||||
|
<!-- Favicon (DJB 로고) -->
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" th:href="@{/img/favicon/favicon-32x32.png}"/>
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" th:href="@{/img/favicon/favicon-16x16.png}"/>
|
||||||
|
<link rel="shortcut icon" type="image/png" th:href="@{/img/favicon/favicon.png}"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" th:href="@{/img/favicon/apple-touch-icon.png}"/>
|
||||||
|
|
||||||
|
<!-- 클라이언트 가드 (우클릭 차단 · DevTools 감지). PortalProperty 토글이 하나라도 켜지면 로드 -->
|
||||||
|
<th:block th:if="${clientGuardContextmenu or clientGuardDevtools}">
|
||||||
|
<script th:inline="javascript">
|
||||||
|
window.__CLIENT_GUARD__ = {
|
||||||
|
contextmenu: /*[[${clientGuardContextmenu}]]*/ false,
|
||||||
|
devtools: /*[[${clientGuardDevtools}]]*/ false
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script th:src="@{/js/djb/client-guard.js}"></script>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
||||||
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
||||||
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
||||||
|
|||||||
@@ -3,16 +3,6 @@
|
|||||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||||
<body>
|
<body>
|
||||||
<th:block th:fragment="headerFragment(headerClass)">
|
<th:block th:fragment="headerFragment(headerClass)">
|
||||||
<!-- Design Survey Bar -->
|
|
||||||
<div th:if="${designSurveyEnabled}" class="design-survey-bar" id="designSurveyBar">
|
|
||||||
<div class="survey-container">
|
|
||||||
<span class="survey-label">네비게이션 디자인을 선택해주세요:</span>
|
|
||||||
<div class="survey-buttons" id="surveyButtons">
|
|
||||||
<!-- 버튼은 JavaScript에서 동적으로 생성됩니다 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Global Header Container -->
|
<!-- Global Header Container -->
|
||||||
<header class="global-header" th:classappend="${headerClass}">
|
<header class="global-header" th:classappend="${headerClass}">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -64,11 +54,6 @@
|
|||||||
<!-- Logout State (Authenticated) -->
|
<!-- Logout State (Authenticated) -->
|
||||||
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
||||||
<div class="header-user-info">
|
<div class="header-user-info">
|
||||||
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
|
|
||||||
<i class="fas fa-clock session-timer-icon"></i>
|
|
||||||
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
|
||||||
</span>
|
|
||||||
<span class="divider">•</span>
|
|
||||||
<div class="user-identity">
|
<div class="user-identity">
|
||||||
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
||||||
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
||||||
@@ -107,10 +92,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mobile-right">
|
<div class="mobile-right">
|
||||||
<span class="session-timer session-timer-mobile" sec:authorize="isAuthenticated()" title="남은 세션 시간">
|
|
||||||
<i class="fas fa-clock session-timer-icon"></i>
|
|
||||||
<span class="session-timer-text">--:--</span>
|
|
||||||
</span>
|
|
||||||
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
|
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
|
||||||
<span class="hamburger-line"></span>
|
<span class="hamburger-line"></span>
|
||||||
<span class="hamburger-line"></span>
|
<span class="hamburger-line"></span>
|
||||||
@@ -123,6 +104,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 우측 상단 Float 세션 타이머 (인증 사용자). 모바일에선 헤더/햄버거 아래로 내려 겹침 방지 -->
|
||||||
|
<div class="session-float" sec:authorize="isAuthenticated()">
|
||||||
|
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
|
||||||
|
<i class="fas fa-clock session-timer-icon"></i>
|
||||||
|
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
||||||
|
</span>
|
||||||
|
<!-- 세션 유지(타임아웃 무시) 체크박스: 비운영 + property 활성 시에만 렌더 (prod 미노출) -->
|
||||||
|
<label class="session-keepalive" th:if="${sessionKeepAliveAllowed}" title="세션 자동 유지 (비운영 전용)">
|
||||||
|
<input type="checkbox" id="sessionKeepAliveToggle">
|
||||||
|
<span>세션 유지</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Drawer/Modal -->
|
<!-- Mobile Drawer/Modal -->
|
||||||
<div class="mobile-drawer" id="mobileDrawer">
|
<div class="mobile-drawer" id="mobileDrawer">
|
||||||
<div class="drawer-overlay" id="drawerOverlay"></div>
|
<div class="drawer-overlay" id="drawerOverlay"></div>
|
||||||
@@ -228,7 +222,6 @@
|
|||||||
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
|
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
|
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
|
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/commission/manage}">과금 관리</a></li>
|
|
||||||
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
|
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
|
||||||
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
|
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -271,6 +264,7 @@
|
|||||||
var countdownTimer = null;
|
var countdownTimer = null;
|
||||||
var promptShown = false; // 연장 확인 모달 중복 표시 방지
|
var promptShown = false; // 연장 확인 모달 중복 표시 방지
|
||||||
var redirecting = false;
|
var redirecting = false;
|
||||||
|
var keepAliveActive = false; // 세션 유지 체크 시 true → 카운트다운 정지, 무제한(∞) 표시
|
||||||
|
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||||||
@@ -298,6 +292,14 @@
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!timerTexts.length) return;
|
if (!timerTexts.length) return;
|
||||||
|
// 세션 유지 활성 시 카운트다운 대신 무제한(∞) 표시
|
||||||
|
if (keepAliveActive) {
|
||||||
|
for (var k = 0; k < timerTexts.length; k++) {
|
||||||
|
timerTexts[k].textContent = '∞';
|
||||||
|
timerTexts[k].classList.remove('session-timer-warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
var label = formatTime(remainingSeconds);
|
var label = formatTime(remainingSeconds);
|
||||||
var warn = remainingSeconds <= WARNING_SECONDS;
|
var warn = remainingSeconds <= WARNING_SECONDS;
|
||||||
for (var i = 0; i < timerTexts.length; i++) {
|
for (var i = 0; i < timerTexts.length; i++) {
|
||||||
@@ -361,6 +363,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
|
// 세션 유지 활성 시 로컬 카운트다운·만료 처리 중단 (heartbeat가 세션 연장)
|
||||||
|
if (keepAliveActive) {
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
remainingSeconds--;
|
remainingSeconds--;
|
||||||
if (remainingSeconds <= 0) {
|
if (remainingSeconds <= 0) {
|
||||||
goTo(EXPIRED_URL);
|
goTo(EXPIRED_URL);
|
||||||
@@ -398,6 +405,44 @@
|
|||||||
render();
|
render();
|
||||||
countdownTimer = setInterval(tick, 1000);
|
countdownTimer = setInterval(tick, 1000);
|
||||||
setInterval(pollStatus, POLL_INTERVAL_MS);
|
setInterval(pollStatus, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
// 세션 유지(타임아웃 무시): 비운영 + DB property 활성 시에만 동작(prod는 서버가 false 주입 → 체크박스도 미렌더).
|
||||||
|
// 체크 시 주기적 heartbeat로 세션을 계속 연장. 탭을 닫으면 heartbeat 중단 → 정상 만료.
|
||||||
|
var KEEPALIVE_ALLOWED = /*[[${sessionKeepAliveAllowed}]]*/ false;
|
||||||
|
var KEEPALIVE_LS_KEY = 'sessionKeepAlive';
|
||||||
|
var KEEPALIVE_INTERVAL_MS = 60000; // 세션 타임아웃보다 충분히 짧게
|
||||||
|
var keepAliveTimer = null;
|
||||||
|
var keepAliveToggle = document.getElementById('sessionKeepAliveToggle');
|
||||||
|
|
||||||
|
function startKeepAlive() {
|
||||||
|
keepAliveActive = true;
|
||||||
|
render(); // 즉시 ∞ 표시
|
||||||
|
if (keepAliveTimer) return;
|
||||||
|
keepAliveTimer = setInterval(extendSession, KEEPALIVE_INTERVAL_MS);
|
||||||
|
extendSession(); // 즉시 1회 연장
|
||||||
|
}
|
||||||
|
function stopKeepAlive() {
|
||||||
|
keepAliveActive = false;
|
||||||
|
if (keepAliveTimer) { clearInterval(keepAliveTimer); keepAliveTimer = null; }
|
||||||
|
render(); // 카운트다운 표시 복귀
|
||||||
|
pollStatus(); // 정지 중 stale 해소: 서버 잔여시간 즉시 재동기화
|
||||||
|
}
|
||||||
|
|
||||||
|
if (KEEPALIVE_ALLOWED && keepAliveToggle) {
|
||||||
|
if (localStorage.getItem(KEEPALIVE_LS_KEY) === 'true') {
|
||||||
|
keepAliveToggle.checked = true;
|
||||||
|
startKeepAlive();
|
||||||
|
}
|
||||||
|
keepAliveToggle.addEventListener('change', function () {
|
||||||
|
if (keepAliveToggle.checked) {
|
||||||
|
localStorage.setItem(KEEPALIVE_LS_KEY, 'true');
|
||||||
|
startKeepAlive();
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(KEEPALIVE_LS_KEY);
|
||||||
|
stopKeepAlive();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -504,107 +549,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Design Survey Configuration
|
|
||||||
// 새 디자인 옵션 추가: DESIGN_OPTIONS 배열에 항목 추가
|
|
||||||
// 예: { id: 'D', label: 'D', styles: { '.logo-text': { 'font-size': '16px' } } }
|
|
||||||
// ============================================================
|
|
||||||
const DESIGN_OPTIONS = [
|
|
||||||
{
|
|
||||||
id: 'A',
|
|
||||||
label: 'A (현재)',
|
|
||||||
isDefault: true,
|
|
||||||
styles: {} // 기본 스타일 (변경 없음)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'B',
|
|
||||||
label: 'B',
|
|
||||||
styles: {
|
|
||||||
'.logo-text': { 'font-size': '18px' },
|
|
||||||
'.nav-link': { 'margin': '0 20px' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'C',
|
|
||||||
label: 'C',
|
|
||||||
styles: {
|
|
||||||
'.nav-link': { 'margin': '0 20px', 'font-weight': 'bold' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 새 디자인 추가 예시:
|
|
||||||
// {
|
|
||||||
// id: 'D',
|
|
||||||
// label: 'D',
|
|
||||||
// styles: {
|
|
||||||
// '.logo-text': { 'font-size': '16px', 'color': '#333' },
|
|
||||||
// '.nav-link': { 'padding': '10px 24px' }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Design Survey Logic
|
|
||||||
const surveyBar = document.getElementById('designSurveyBar');
|
|
||||||
if (surveyBar) {
|
|
||||||
const body = document.body;
|
|
||||||
const buttonContainer = document.getElementById('surveyButtons');
|
|
||||||
const STORAGE_KEY = 'design-survey-selection';
|
|
||||||
let styleElement = null;
|
|
||||||
|
|
||||||
body.classList.add('design-survey-active');
|
|
||||||
|
|
||||||
// 버튼 동적 생성
|
|
||||||
DESIGN_OPTIONS.forEach(option => {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'survey-btn';
|
|
||||||
btn.dataset.design = option.id;
|
|
||||||
btn.textContent = option.label;
|
|
||||||
buttonContainer.appendChild(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
const surveyButtons = buttonContainer.querySelectorAll('.survey-btn');
|
|
||||||
const defaultOption = DESIGN_OPTIONS.find(o => o.isDefault) || DESIGN_OPTIONS[0];
|
|
||||||
const savedDesign = localStorage.getItem(STORAGE_KEY) || defaultOption.id;
|
|
||||||
|
|
||||||
applyDesign(savedDesign);
|
|
||||||
surveyButtons.forEach(btn => {
|
|
||||||
btn.classList.toggle('active', btn.dataset.design === savedDesign);
|
|
||||||
});
|
|
||||||
|
|
||||||
surveyButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
surveyButtons.forEach(b => b.classList.remove('active'));
|
|
||||||
this.classList.add('active');
|
|
||||||
applyDesign(this.dataset.design);
|
|
||||||
localStorage.setItem(STORAGE_KEY, this.dataset.design);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function applyDesign(designId) {
|
|
||||||
// 기존 동적 스타일 제거
|
|
||||||
if (styleElement) {
|
|
||||||
styleElement.remove();
|
|
||||||
styleElement = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const option = DESIGN_OPTIONS.find(o => o.id === designId);
|
|
||||||
if (!option || Object.keys(option.styles).length === 0) return;
|
|
||||||
|
|
||||||
// 동적 스타일 생성
|
|
||||||
let css = '';
|
|
||||||
for (const [selector, props] of Object.entries(option.styles)) {
|
|
||||||
const propsStr = Object.entries(props)
|
|
||||||
.map(([prop, val]) => `${prop}: ${val} !important`)
|
|
||||||
.join('; ');
|
|
||||||
css += `${selector} { ${propsStr}; }\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
styleElement = document.createElement('style');
|
|
||||||
styleElement.id = 'design-survey-styles';
|
|
||||||
styleElement.textContent = css;
|
|
||||||
document.head.appendChild(styleElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|||||||