Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37be9a361a |
@@ -1,51 +1,57 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent none
|
agent { label 'djb-vm' }
|
||||||
|
|
||||||
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 {
|
|
||||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
|
||||||
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
|
|
||||||
}
|
|
||||||
|
|
||||||
stages {
|
|
||||||
stage('Build (djb-vm)') {
|
|
||||||
agent { label 'djb-vm' }
|
|
||||||
environment {
|
environment {
|
||||||
JAVA_HOME = '/apps/opts/jdk8'
|
JAVA_HOME = '/apps/opts/jdk8'
|
||||||
|
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
|
||||||
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||||
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||||
NODE_HOME = '/apps/opts/node-v24'
|
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}"
|
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'
|
||||||
|
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'
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
stage('Checkout') {
|
stage('Checkout') {
|
||||||
steps { checkout scm }
|
steps {
|
||||||
|
checkout scm
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stage('Checkout dependencies') {
|
stage('Checkout dependencies') {
|
||||||
steps {
|
steps {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
cd "$WORKSPACE/.."
|
cd "$WORKSPACE/.."
|
||||||
|
|
||||||
if [ ! -d elink-portal-common/.git ]; then
|
if [ ! -d elink-portal-common/.git ]; then
|
||||||
rm -rf elink-portal-common
|
rm -rf elink-portal-common
|
||||||
git clone --depth=1 --branch master \
|
git clone --depth=1 --branch master \
|
||||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git 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 \
|
||||||
|
eapim-online/elink-online-core-jpa
|
||||||
else
|
else
|
||||||
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
||||||
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
||||||
@@ -54,11 +60,21 @@ pipeline {
|
|||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Verify toolchain') {
|
stage('Verify toolchain') {
|
||||||
steps { sh 'set -eu; java -version; gradle --version' }
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
java -version
|
||||||
|
gradle --version
|
||||||
|
'''
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stage('Test') {
|
stage('Test') {
|
||||||
steps { sh 'gradle clean test --no-daemon -Pprofile=weblogic' }
|
steps {
|
||||||
|
sh 'gradle clean test --no-daemon'
|
||||||
|
}
|
||||||
post {
|
post {
|
||||||
always {
|
always {
|
||||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||||
@@ -66,94 +82,139 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Build WAR') {
|
stage('Build WAR') {
|
||||||
steps { sh 'gradle build -x test --no-daemon -Pprofile=weblogic' }
|
steps {
|
||||||
|
sh 'gradle build -x test --no-daemon'
|
||||||
|
}
|
||||||
post {
|
post {
|
||||||
success {
|
success {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
cd build/libs
|
cd build/libs
|
||||||
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
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.war.sha256', fingerprint: true
|
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
|
||||||
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Deploy (weblogic)') {
|
stage('Stop Tomcat') {
|
||||||
agent { label 'weblogic' }
|
|
||||||
environment {
|
|
||||||
JENKINS_NODE_COOKIE = 'dontKillMe' // background weblogic를 빌드 종료 시 죽이지 않게
|
|
||||||
WL_HOME = '/app/eapim/devportal'
|
|
||||||
WL_DEPLOY_DIR = '/app/eapim/devportal'
|
|
||||||
WL_WAR_NAME = 'eapim-portal.war'
|
|
||||||
WL_HTTP_PORT = '39130'
|
|
||||||
WL_NOHUP = '/logs/weblogic/domains/eapimDomain/nohup.devSvr11.out'
|
|
||||||
}
|
|
||||||
stages {
|
|
||||||
stage('Stop WebLogic') {
|
|
||||||
steps {
|
steps {
|
||||||
sh '"$WL_HOME/stopDev11.sh"' // 동기: 완전 종료까지 블록, 미기동 시에도 안전
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stage('Deploy WAR') {
|
|
||||||
steps {
|
|
||||||
unstash 'war'
|
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set +e
|
||||||
cp build/libs/eapim-portal.war "$WL_DEPLOY_DIR/$WL_WAR_NAME"
|
systemctl --user stop eapim-portal 2>/dev/null
|
||||||
ls -la "$WL_DEPLOY_DIR"
|
|
||||||
|
if [ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null; then
|
||||||
|
JAVA_HOME="$JAVA_HOME_TOMCAT" "$CATALINA_HOME/bin/shutdown.sh" 30 -force || true
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
[ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null || break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
rm -f "$CATALINA_PID"
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stage('Start WebLogic and readiness') {
|
|
||||||
|
stage('Deploy ROOT.war') {
|
||||||
steps {
|
steps {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
"$WL_HOME/startDev11.sh" # nohup & 로 백그라운드 기동, 즉시 리턴
|
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"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DEADLINE=$(($(date +%s) + 300))
|
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
|
STATUS=000
|
||||||
|
|
||||||
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||||
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
|
if [ "$LIVE_OK" = "0" ]; then
|
||||||
"http://localhost:$WL_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
|
LIVE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||||
[ "$STATUS" = "200" ] && { echo "Readiness OK (/health/ready)"; break; }
|
"http://localhost:$DEPLOY_HTTP_PORT/health" 2>/dev/null || echo "000")
|
||||||
sleep 3
|
if [ "$LIVE" = "200" ]; then
|
||||||
|
echo "Liveness OK (/health)"
|
||||||
|
LIVE_OK=1
|
||||||
|
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
|
done
|
||||||
|
|
||||||
if [ "$STATUS" != "200" ]; then
|
if [ "$STATUS" != "200" ]; then
|
||||||
echo "Readiness failed within 300s, last HTTP=$STATUS"
|
echo "Readiness probe failed within 120s, last HTTP status: $STATUS"
|
||||||
[ -f "$WL_NOHUP" ] && tail -120 "$WL_NOHUP"
|
[ -f /tmp/eapim-ready-body.json ] && cat /tmp/eapim-ready-body.json && echo
|
||||||
|
[ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | tail -100
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 {
|
||||||
node('weblogic') {
|
|
||||||
sh '''
|
sh '''
|
||||||
set +e
|
set +e
|
||||||
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
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}]}]}' \
|
||||||
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
|
||||||
|
curl -sS --fail --max-time 5 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-X POST \
|
||||||
|
--data "$payload" \
|
||||||
|
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for SUCCESS"
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
|
||||||
failure {
|
failure {
|
||||||
node('weblogic') {
|
|
||||||
sh '''
|
sh '''
|
||||||
set +e
|
set +e
|
||||||
payload=$(printf '{"text":"[%s #%s](%s) FAILURE"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
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}]}]}' \
|
||||||
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
|
||||||
|
curl -sS --fail --max-time 5 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-X POST \
|
||||||
|
--data "$payload" \
|
||||||
|
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for FAILURE"
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"name": "eapim-portal",
|
"name": "eapim-portal",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.61.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
}
|
}
|
||||||
@@ -349,6 +352,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
@@ -403,6 +420,36 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
|
|||||||
@@ -13,5 +13,8 @@
|
|||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.61.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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;
|
||||||
@@ -22,7 +21,6 @@ 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;
|
||||||
@@ -46,7 +44,6 @@ 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
|
||||||
@@ -80,7 +77,6 @@ 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
|
||||||
|
|
||||||
@@ -172,13 +168,7 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -238,7 +228,10 @@ public class MyAppController {
|
|||||||
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
|
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
|
||||||
Map<String, Object> result = new java.util.HashMap<>();
|
Map<String, Object> result = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
String clientId = requestData.get("clientId");
|
String clientId = requestData.get("clientId");
|
||||||
|
String type = requestData.get("type");
|
||||||
|
|
||||||
if (clientId == null || clientId.trim().isEmpty()) {
|
if (clientId == null || clientId.trim().isEmpty()) {
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
result.put("msg", "클라이언트 ID가 필요합니다.");
|
result.put("msg", "클라이언트 ID가 필요합니다.");
|
||||||
@@ -246,37 +239,17 @@ public class MyAppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
String orgId = user.getPortalOrg().getId();
|
|
||||||
|
|
||||||
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
|
// API Key 즉시 삭제 (승인 프로세스 없이)
|
||||||
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
|
appServiceFacade.deleteApp(user.getPortalOrg().getId(), clientId);
|
||||||
result.put("success", false);
|
|
||||||
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
|
|
||||||
try {
|
|
||||||
adminGatewayClient.blockClient(clientId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
|
|
||||||
result.put("success", false);
|
|
||||||
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. GW 차단 성공 시에만 포털 credential 삭제
|
|
||||||
try {
|
|
||||||
appServiceFacade.deleteApp(orgId, clientId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
|
|
||||||
result.put("success", false);
|
|
||||||
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.put("success", true);
|
result.put("success", true);
|
||||||
result.put("msg", "API Key가 삭제되었습니다.");
|
result.put("msg", "API Key가 삭제되었습니다.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,57 +282,6 @@ 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 요청 이력을 페이지 형태로 조회합니다.
|
||||||
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
|
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
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,29 +293,6 @@ 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)를 즉시 삭제합니다.
|
||||||
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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|REVIEWING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
@Pattern(regexp = "^(PENDING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
||||||
private String inquiryStatus;
|
private String inquiryStatus;
|
||||||
|
|
||||||
private String inquirerName;
|
private String inquirerName;
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import javax.servlet.http.HttpServletRequest;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
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 +16,15 @@ 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;
|
|
||||||
|
|
||||||
@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,6 +37,17 @@ 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();
|
||||||
@@ -59,22 +70,4 @@ public class GlobalControllerAdvice {
|
|||||||
public int sessionTimeoutMinutes() {
|
public int sessionTimeoutMinutes() {
|
||||||
return userSessionService.getSessionTimeoutMinutes();
|
return userSessionService.getSessionTimeoutMinutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 클라이언트 가드 - 우클릭(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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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,16 +160,10 @@ 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;
|
String writerName = writerId != null ? writerNames.get(writerId) : null;
|
||||||
if ("Y".equals(entity.getAdminYn())) {
|
|
||||||
// 관리자 댓글은 실명 대신 "관리자"로만 표기 (신원 비노출)
|
|
||||||
writerName = "관리자";
|
|
||||||
} else {
|
|
||||||
writerName = writerId != null ? writerNames.get(writerId) : null;
|
|
||||||
if (writerName == null || writerName.isEmpty()) {
|
if (writerName == null || writerName.isEmpty()) {
|
||||||
writerName = "(알 수 없음)";
|
writerName = "(알 수 없음)";
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return InquiryCommentDTO.builder()
|
return InquiryCommentDTO.builder()
|
||||||
.id(entity.getId())
|
.id(entity.getId())
|
||||||
.content(entity.getCommentDetail())
|
.content(entity.getCommentDetail())
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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";
|
||||||
|
|
||||||
@@ -13,37 +12,4 @@ 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 및 기본값
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ server:
|
|||||||
session:
|
session:
|
||||||
timeout: 10m
|
timeout: 10m
|
||||||
cookie:
|
cookie:
|
||||||
name: JSESSIONID_PORTAL
|
name: PORTAL_JSESSIONID
|
||||||
encoding:
|
encoding:
|
||||||
charset: UTF-8
|
charset: UTF-8
|
||||||
port: '39130'
|
port: '39130'
|
||||||
@@ -378,6 +378,9 @@ 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"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 729 KiB |
|
Before Width: | Height: | Size: 712 KiB After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 1.3 MiB |
@@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
+ '<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>'
|
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
|
||||||
|
+ (c.adminYn === 'Y' ? ' <span class="djb-comment-admin-badge">관리자</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>'
|
||||||
|
|||||||
@@ -35,22 +35,22 @@
|
|||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
transition: $transition-base;
|
transition: $transition-base;
|
||||||
|
|
||||||
// Box style for Figma design (node-id: 721-1489)
|
// Figma GNB 로그인 버튼: pill (#F2F5F7 배경 / #0046FF 보더·텍스트 / radius 20px)
|
||||||
&-box {
|
&-box {
|
||||||
height: 32px;
|
height: 34px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px; // 아이콘-텍스트 간격
|
gap: 4px; // 아이콘-텍스트 간격 (Figma)
|
||||||
padding: 6px 16px;
|
padding: 6px 16px 6px 12px;
|
||||||
color: #212529; // 강조 텍스트 색상
|
color: #0046FF; // Figma 로그인 텍스트 블루
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: none;
|
border: 1px solid #0046FF;
|
||||||
border-radius: 6px;
|
border-radius: 20px;
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-weight: $font-weight-semibold; // 600
|
font-weight: $font-weight-regular; // 400 (Figma)
|
||||||
font-size: 16px;
|
font-size: 19px;
|
||||||
background: transparent;
|
background: #F2F5F7; // Figma 그레이2 pill 배경
|
||||||
transition: $transition-base;
|
transition: $transition-base;
|
||||||
|
|
||||||
.user-icon {
|
.user-icon {
|
||||||
@@ -58,6 +58,10 @@
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #E9EDF2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
.footer-logo {
|
.footer-logo {
|
||||||
width: 114px;
|
width: 144px; // Figma: 제주은행 로고 144x34
|
||||||
height: auto;
|
height: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
.footer-link {
|
.footer-link {
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 400;
|
font-weight: 700; // Figma: 링크 Bold
|
||||||
color: #5F666C;
|
color: #5F666C;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.3s ease;
|
transition: color 0.3s ease;
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-separator {
|
.footer-separator {
|
||||||
color: #D1D5DB;
|
color: #5F666C; // Figma 구분자
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #9CA3AF;
|
color: #5F666C; // Figma: 저작권 그레이
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
@@ -103,8 +103,8 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #5F666C;
|
color: #5F666C;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #D1D5DB;
|
border: 1px solid #5F666C; // Figma: 관련사이트 박스 보더
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -131,14 +131,14 @@
|
|||||||
|
|
||||||
.footer-contact {
|
.footer-contact {
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 18px;
|
font-size: 30px; // Figma: 고객센터 30px Bold
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1F2937;
|
color: #5F666C; // Figma 그레이
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
font-size: 16px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,80 @@
|
|||||||
--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;
|
||||||
@@ -53,13 +127,14 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
z-index: $z-index-header;
|
z-index: $z-index-header;
|
||||||
height: 112px;
|
height: 110px; // Figma GNB 높이
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
border-bottom: 1px solid #dddddd;
|
border-bottom: 1px solid #dddddd;
|
||||||
|
|
||||||
// Index page header with light blue background
|
// Figma GNB: 흰색 바 (히어로 시안 그라데이션과 분리)
|
||||||
&.index-header {
|
&.index-header {
|
||||||
background-color: #E5F8FF;
|
background-color: #FFFFFF;
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.container {
|
.container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -127,10 +202,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 22px;
|
font-size: 23px; // Figma: API Portal 23px Medium
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
color: var(--text-dark);
|
color: #000000;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -910,8 +985,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-dark);
|
color: #212529; // Figma 강조텍스트
|
||||||
font-weight: 500;
|
font-weight: 700; // Figma: 네비 Bold
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
@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 *;
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,36 @@
|
|||||||
@use '../abstracts/color-functions' as *;
|
@use '../abstracts/color-functions' as *;
|
||||||
@use '../abstracts/mixins' as *;
|
@use '../abstracts/mixins' as *;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 캐러셀 + 검색 + 추천: 이어지는 단일 배경 (Figma 연속 배경)
|
||||||
|
// - 상단 시안 그라데이션(히어로) → 연한 시안(#CFF3FF)으로 흐른 뒤
|
||||||
|
// - 하단에 파도/버블 이미지(추천)가 같은 #CFF3FF 상단색으로 이음새 없이 연결
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
.landing-top {
|
||||||
|
position: relative;
|
||||||
|
// Figma에서 추출한 단일 연속 배경 이미지 (히어로 그라데이션 + 곡선 + 추천 파도/버블)
|
||||||
|
background-color: #D4F0FC;
|
||||||
|
background-image: url('/img/bg_landing_top.png');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
background-position: top center;
|
||||||
|
|
||||||
|
// 하위 섹션 배경 투명 — wrapper 배경이 끊김 없이 이어지도록
|
||||||
|
.hero-carousel-section,
|
||||||
|
.hero-carousel-container,
|
||||||
|
.hero-slide,
|
||||||
|
.api-search-section,
|
||||||
|
.api-showcase {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개별 섹션 배경 레이어 제거 (wrapper로 통합)
|
||||||
|
.showcase-background,
|
||||||
|
.search-background {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Hero Carousel Section - Figma Design
|
// Hero Carousel Section - Figma Design
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -17,7 +47,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 580px;
|
height: 580px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #E9F9FF;
|
// Figma 히어로 배경: 상단 연한 시안 → 하단 스카이 시안 (실측 픽셀 기준)
|
||||||
|
background: linear-gradient(180deg, #BAF5FA 0%, #76D4FC 100%);
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
height: 800px;
|
height: 800px;
|
||||||
@@ -60,15 +91,15 @@
|
|||||||
|
|
||||||
// Slide backgrounds
|
// Slide backgrounds
|
||||||
&[data-slide="0"] {
|
&[data-slide="0"] {
|
||||||
background: #E9F9FF;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-slide="1"] {
|
&[data-slide="1"] {
|
||||||
background: #E9F9FF;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-slide="2"] {
|
&[data-slide="2"] {
|
||||||
background: #E9F9FF;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +207,7 @@
|
|||||||
width: 178px;
|
width: 178px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #0049B4;
|
background: #2A69DE;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -186,9 +217,9 @@
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken(#0049B4, 10%);
|
background: darken(#2A69DE, 10%);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.3);
|
box-shadow: 0 4px 12px rgba(42, 105, 222, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@@ -421,11 +452,13 @@
|
|||||||
|
|
||||||
.api-search-section {
|
.api-search-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0;
|
padding: 56px 20px 64px;
|
||||||
//margin-top: -80px;
|
overflow: visible;
|
||||||
overflow: hidden;
|
|
||||||
background: #e9f9ff;
|
background: #e9f9ff;
|
||||||
height: 314px;
|
|
||||||
|
@include respond-to('sm') {
|
||||||
|
padding: 28px 16px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-background {
|
.search-background {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -437,111 +470,153 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-content-wrapper {
|
// Figma: 글래스모피즘 카드 (반투명 화이트 + 흰 보더 + 섀도, radius 30)
|
||||||
|
.search-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1308px;
|
||||||
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 56px;
|
||||||
gap: 40px;
|
padding: 40px 56px;
|
||||||
z-index: 1;
|
border-radius: 30px;
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
border: 1px solid #FFFFFF;
|
||||||
|
box-shadow: 0 10px 24px rgba(141, 155, 185, 0.25);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding-top: 40px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 캐릭터와 텍스트 가로 배치
|
gap: 16px;
|
||||||
flex-direction: row;
|
padding: 20px;
|
||||||
flex-wrap: wrap;
|
border-radius: 20px;
|
||||||
gap: 8px;
|
|
||||||
padding-top: 20px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-character {
|
|
||||||
width: 154px;
|
|
||||||
height: 148px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
width: 120px;
|
|
||||||
height: 115px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 68x65px
|
|
||||||
width: 68px;
|
|
||||||
height: 65px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-text-content {
|
.search-text-content {
|
||||||
text-align: left;
|
flex-shrink: 0;
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
text-align: center;
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 텍스트 왼쪽 정렬
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-title {
|
.search-title {
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
line-height: 1.3;
|
font-weight: 500;
|
||||||
|
line-height: 1.25;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 16px, Bold
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.4;
|
line-height: 1.3;
|
||||||
|
color: #212529;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
|
@include respond-to('sm') {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자주찾는 API + 보라 알약 태그
|
||||||
|
.hashtag-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
@include respond-to('sm') {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag-label {
|
||||||
|
font-family: $font-family-primary;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000000;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@include respond-to('sm') {
|
||||||
|
font-size: 12px;
|
||||||
color: #212529;
|
color: #212529;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input-wrapper {
|
.hashtag-list {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
padding: 0px 0 30px;
|
gap: 10px;
|
||||||
z-index: 1;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('sm') {
|
||||||
padding: 30px 20px 20px;
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Figma: 보라색 알약 태그 (#4546CE)
|
||||||
|
.hashtag-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #4546CE;
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: $font-family-primary;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken(#4546CE, 8%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 좌측 27px, 우측 28px 패딩으로 좌측 정렬
|
padding: 4px 10px;
|
||||||
justify-content: flex-start;
|
font-size: 11px;
|
||||||
padding: 0px 28px 12px 27px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 알약 태그라 텍스트 구분자(|) 숨김
|
||||||
|
.hashtag-separator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.search-form {
|
.search-form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 816px;
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 320px 고정 너비
|
|
||||||
//width: 320px;
|
|
||||||
//max-width: 320px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box {
|
.search-box {
|
||||||
@@ -556,16 +631,15 @@
|
|||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
height: 60px;
|
height: 64px;
|
||||||
border: 4px solid #0049B4;
|
border-width: 4px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 320x36px, 1px 파란색 테두리
|
height: 44px;
|
||||||
height: 36px;
|
border-width: 3px;
|
||||||
border: 3px solid #0049B4;
|
border-radius: 22px;
|
||||||
border-radius: 18px;
|
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,7 +655,7 @@
|
|||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
padding: 0 20px;
|
padding: 0 12px;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #B3B3B3;
|
color: #B3B3B3;
|
||||||
@@ -590,18 +664,13 @@
|
|||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
padding: 0 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 11px, Medium, placeholder 색상 #8C959F
|
font-size: 12px;
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0 8px;
|
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #8C959F;
|
color: #8C959F;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,119 +706,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 16x16px 아이콘
|
width: 18px;
|
||||||
width: 16px;
|
height: 18px;
|
||||||
height: 16px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
width: 20px;
|
width: 24px;
|
||||||
height: 20px;
|
height: 24px;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-section {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0 20px 0px;
|
|
||||||
z-index: 1;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 20px 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 가로 배치, 작은 간격
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0 20px 20px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-label {
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #000000;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 12px, Bold
|
|
||||||
font-size: 12px;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-list {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 8px 간격, wrap
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-link {
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #000000;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #0049B4;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 12px
|
|
||||||
font-size: 12px;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-separator {
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #000000;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 세로 구분선 (|)
|
|
||||||
font-size: 10px;
|
|
||||||
color: #212529;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 10px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -863,7 +827,7 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background: #008ae2;
|
background: #2A69DE;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
@@ -910,7 +874,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken(#008ae2, 10%);
|
background: darken(#2A69DE, 10%);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
@@ -952,14 +916,49 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.3s ease;
|
overflow: hidden;
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
|
||||||
gap: 0px;
|
gap: 0px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
// Figma: hover 시 파란 세로 그라데이션 (카드4 = hover 예시 상태)
|
||||||
|
// 그라데이션은 transition 직접 적용 불가 → 오버레이 opacity로 over/out 부드럽게 페이드
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(180deg, #50CAFF 0%, #2088FF 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 콘텐츠를 오버레이 위로
|
||||||
|
> * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title,
|
||||||
|
.card-description {
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
border-color: transparent;
|
||||||
border-color: #008ae2;
|
box-shadow: 0 10px 28px rgba(0, 14, 27, 0.22);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title,
|
||||||
|
.card-description {
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
@@ -1043,7 +1042,7 @@
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Info Section - 광주은행 API 소개 (Figma Design)
|
// Info Section - 제주은행(DJBank) API 소개 (Figma Design)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
.info-section {
|
.info-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1060,13 +1059,28 @@
|
|||||||
padding: 75px 20px 20px;
|
padding: 75px 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Figma 배경 이미지: 좌측 블루 카드(우하단 라운드 baked), 우측은 흰 영역(모니터)
|
||||||
.info-background {
|
.info-background {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: 0;
|
||||||
background-image: url('/img/bg_api_intro.svg');
|
bottom: 0;
|
||||||
background-size: cover;
|
left: 0;
|
||||||
background-position: center;
|
width: 54%; // 텍스트(좌측 콘텐츠) 폭을 수용하면서 모니터와 겹치지 않는 폭
|
||||||
|
background-image: url('/img/bg_diff_card.png');
|
||||||
|
background-size: 100% 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: left top;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
@include respond-to('md') {
|
||||||
|
// 태블릿: 컬럼 레이아웃 → 전체 블루
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include respond-to('sm') {
|
||||||
|
// 모바일: 흰 배경 + 다크 텍스트(기존 sm 오버라이드) 유지
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//.info-point-image {
|
//.info-point-image {
|
||||||
@@ -1131,7 +1145,7 @@
|
|||||||
.info-title {
|
.info-title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: #000000;
|
color: #FFFFFF; // Figma: 블루 카드 위 흰 텍스트
|
||||||
margin: 0 0 $spacing-lg 0;
|
margin: 0 0 $spacing-lg 0;
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
@@ -1154,7 +1168,7 @@
|
|||||||
|
|
||||||
.title-highlight {
|
.title-highlight {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0049B4;
|
color: #0FDBFF; // Figma: 브랜드 라인 시안 강조
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
@@ -1172,7 +1186,7 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #000000;
|
color: #FFFFFF; // Figma: 블루 카드 위 흰 텍스트
|
||||||
margin: 0 0 $spacing-3xl 0;
|
margin: 0 0 $spacing-3xl 0;
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
@@ -1210,17 +1224,17 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 18px 52px;
|
padding: 0 28px;
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 24px;
|
font-size: 18px; // Figma: 18px Bold
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 56px;
|
line-height: 1.2;
|
||||||
border-radius: 20px;
|
border-radius: 14px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
min-width: 310px;
|
min-width: 0;
|
||||||
height: 128px;
|
height: 60px; // Figma: 버튼 높이 ~71px → 카드 내 적정 60px
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 160x72px, 14px Bold, border-radius 8px
|
// Figma 모바일: 160x72px, 14px Bold, border-radius 8px
|
||||||
@@ -1251,27 +1265,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Figma: '처음 만나는 DJBank API' = 연한 파랑 배경 + 네이비 텍스트
|
||||||
.action-btn-primary {
|
.action-btn-primary {
|
||||||
background: #0049B4;
|
background: #D5EFFF;
|
||||||
color: #FFFFFF;
|
color: #211894;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken(#0049B4, 10%);
|
background: darken(#D5EFFF, 8%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Figma: '피드백/개선요청' = 진한 블루 배경 + 흰 텍스트, 테두리 없음
|
||||||
.action-btn-secondary {
|
.action-btn-secondary {
|
||||||
background: #00ACDD;
|
background: #0046FF;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
// hover 반전: 흰 배경 + 블루 텍스트
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken(#00ACDD, 10%);
|
background: #FFFFFF;
|
||||||
|
color: #0046FF;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-image {
|
.info-image {
|
||||||
flex: 1;
|
flex: 0 1 460px;
|
||||||
max-width: 560px;
|
max-width: 460px; // 모니터 일러스트 축소 — 블루 카드 영역 침범 방지
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1331,6 +1350,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.support-background {
|
.support-background {
|
||||||
|
// Figma: 깔끔한 #eef7fd 단색 배경. 구 광주은행 장식 이미지 제거.
|
||||||
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -1696,7 +1717,8 @@
|
|||||||
|
|
||||||
// FAQ Card Specifics
|
// FAQ Card Specifics
|
||||||
&--faq {
|
&--faq {
|
||||||
background: #DAF0FF;
|
background: #CAEAFF;
|
||||||
|
border: 1px solid #97D5FF;
|
||||||
|
|
||||||
.card-content h3 {
|
.card-content h3 {
|
||||||
color: #212529;
|
color: #212529;
|
||||||
@@ -1705,7 +1727,8 @@
|
|||||||
|
|
||||||
// Q&A Card Specifics
|
// Q&A Card Specifics
|
||||||
&--qna {
|
&--qna {
|
||||||
background: #D9F6F8;
|
background: #C7F6F9;
|
||||||
|
border: 1px solid #99E0CC;
|
||||||
|
|
||||||
.card-content h3 {
|
.card-content h3 {
|
||||||
color: #212529;
|
color: #212529;
|
||||||
@@ -2074,7 +2097,7 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
width: 178px;
|
width: 178px;
|
||||||
background: #008ae2;
|
background: #4685EF;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -2084,9 +2107,9 @@
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken(#008ae2, 10%);
|
background: darken(#4685EF, 10%);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 138, 226, 0.3);
|
box-shadow: 0 4px 12px rgba(70, 133, 239, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@@ -2105,6 +2128,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Figma 1:236 — 좌/우 3D 캐릭터 일러스트 (image4 / image6)
|
||||||
|
.cta-character {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
height: auto;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@include respond-to('md') {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-character-left {
|
||||||
|
left: 0;
|
||||||
|
width: clamp(180px, 21.6vw, 416px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-character-right {
|
||||||
|
right: 0;
|
||||||
|
width: clamp(180px, 21.1vw, 406px);
|
||||||
|
}
|
||||||
|
|
||||||
.cta-divider {
|
.cta-divider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -384,11 +384,6 @@
|
|||||||
background-color: #8c959f;
|
background-color: #8c959f;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검토중 - 주황색 배경
|
|
||||||
&--reviewing {
|
|
||||||
background-color: #e08e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 종료 - 짙은 회색 배경
|
// 종료 - 짙은 회색 배경
|
||||||
&--closed {
|
&--closed {
|
||||||
background-color: #4a5560;
|
background-color: #4a5560;
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<!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>
|
||||||
@@ -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="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
|
||||||
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
|
||||||
답변대기
|
답변대기
|
||||||
</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="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
|
||||||
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
|
||||||
답변대기
|
답변대기
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<!-- 캐러셀 + 검색 + 추천: 이어지는 배경 그룹 -->
|
||||||
|
<div class="landing-top">
|
||||||
<!-- Hero Carousel Section -->
|
<!-- Hero Carousel Section -->
|
||||||
<section class="hero-carousel-section">
|
<section class="hero-carousel-section">
|
||||||
<!-- Carousel Container -->
|
<!-- Carousel Container -->
|
||||||
@@ -96,21 +98,27 @@
|
|||||||
<!-- API 검색 섹션 -->
|
<!-- API 검색 섹션 -->
|
||||||
<section class="api-search-section">
|
<section class="api-search-section">
|
||||||
<div class="search-background"></div>
|
<div class="search-background"></div>
|
||||||
<div class="search-content-wrapper">
|
<div class="search-card">
|
||||||
<div class="search-character">
|
|
||||||
<img th:src="@{/img/img_search_character.png}" alt="검색">
|
|
||||||
</div>
|
|
||||||
<div class="search-text-content">
|
<div class="search-text-content">
|
||||||
<h2 class="search-title">
|
<h2 class="search-title">
|
||||||
원하는 API를<br>
|
원하는 API를<br>
|
||||||
지금 검색해 보세요.
|
지금 검색해 보세요.
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="search-main">
|
||||||
|
<div class="hashtag-section">
|
||||||
|
<span class="hashtag-label">자주찾는 API</span>
|
||||||
|
<div class="hashtag-list" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">
|
||||||
|
<th:block th:each="hashtag, iterStat : ${hashtags}">
|
||||||
|
<a href="#" class="hashtag-link" th:data-search="${hashtag}" th:text="${'#' + hashtag}"></a>
|
||||||
|
<span class="hashtag-separator" th:unless="${iterStat.last}">|</span>
|
||||||
|
</th:block>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-input-wrapper">
|
<div class="search-input-wrapper">
|
||||||
<form th:action="@{/apis}" method="get" class="search-form">
|
<form th:action="@{/apis}" method="get" class="search-form">
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input type="text" class="search-input" placeholder="어떤 API를 칮고 계신가요?" id="apiSearchInput" name="keyword">
|
<input type="text" class="search-input" placeholder="어떤 API를 찾고 계신가요?" id="apiSearchInput" name="keyword">
|
||||||
<button class="search-icon-button" type="submit">
|
<button class="search-icon-button" type="submit">
|
||||||
<svg width="33" height="34" viewBox="0 0 33 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="33" height="34" viewBox="0 0 33 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M15.1738 0C23.5534 0.000258465 30.3465 6.79327 30.3467 15.1729C30.3467 18.6141 29.2001 21.7875 27.2695 24.333C27.2733 24.3367 27.2775 24.34 27.2812 24.3438L32.1348 29.1973C33.1223 30.1849 33.1223 31.7859 32.1348 32.7734C31.1472 33.7609 29.5461 33.7609 28.5586 32.7734L23.7051 27.9199C23.6653 27.8802 23.6293 27.8376 23.5928 27.7959C21.1837 29.406 18.2889 30.3466 15.1738 30.3467C6.79392 30.3467 0 23.5528 0 15.1729C0.000201956 6.79311 6.79404 0 15.1738 0ZM15.1738 5.05762C9.58735 5.05762 5.05782 9.58642 5.05762 15.1729C5.05762 20.7595 9.58722 25.2891 15.1738 25.2891C20.7602 25.2888 25.2891 20.7593 25.2891 15.1729C25.2889 9.58658 20.7601 5.05788 15.1738 5.05762Z" fill="#0049B4"/>
|
<path d="M15.1738 0C23.5534 0.000258465 30.3465 6.79327 30.3467 15.1729C30.3467 18.6141 29.2001 21.7875 27.2695 24.333C27.2733 24.3367 27.2775 24.34 27.2812 24.3438L32.1348 29.1973C33.1223 30.1849 33.1223 31.7859 32.1348 32.7734C31.1472 33.7609 29.5461 33.7609 28.5586 32.7734L23.7051 27.9199C23.6653 27.8802 23.6293 27.8376 23.5928 27.7959C21.1837 29.406 18.2889 30.3466 15.1738 30.3467C6.79392 30.3467 0 23.5528 0 15.1729C0.000201956 6.79311 6.79404 0 15.1738 0ZM15.1738 5.05762C9.58735 5.05762 5.05782 9.58642 5.05762 15.1729C5.05762 20.7595 9.58722 25.2891 15.1738 25.2891C20.7602 25.2888 25.2891 20.7593 25.2891 15.1729C25.2889 9.58658 20.7601 5.05788 15.1738 5.05762Z" fill="#0049B4"/>
|
||||||
@@ -119,14 +127,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="hashtag-section">
|
|
||||||
<span class="hashtag-label">자주찾는 API</span>
|
|
||||||
<div class="hashtag-list" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">
|
|
||||||
<th:block th:each="hashtag, iterStat : ${hashtags}">
|
|
||||||
<a href="#" class="hashtag-link" th:data-search="${hashtag}" th:text="${'#' + hashtag}">
|
|
||||||
</a>
|
|
||||||
<span class="hashtag-separator" th:unless="${iterStat.last}">|</span>
|
|
||||||
</th:block>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -136,8 +136,8 @@
|
|||||||
<div class="showcase-background"></div>
|
<div class="showcase-background"></div>
|
||||||
<div class="showcase-wrapper">
|
<div class="showcase-wrapper">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2 class="section-title">추천 API</h2>
|
<h2 class="section-title">추천 API 그룹</h2>
|
||||||
<p class="section-subtitle">다양한 금융 API상품을 소개합니다.</p>
|
<p class="section-subtitle">다양한 금융 API를 제공합니다.</p>
|
||||||
<a th:href="@{/apis}" class="btn-all-apis">
|
<a th:href="@{/apis}" class="btn-all-apis">
|
||||||
전체보기
|
전체보기
|
||||||
<svg width="9" height="15" viewBox="0 0 9 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="9" height="15" viewBox="0 0 9 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -168,6 +168,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
<!-- /landing-top -->
|
||||||
|
|
||||||
<!-- 정보 섹션 -->
|
<!-- 정보 섹션 -->
|
||||||
<section class="info-section">
|
<section class="info-section">
|
||||||
@@ -279,7 +281,7 @@
|
|||||||
<div class="stats-divider"></div>
|
<div class="stats-divider"></div>
|
||||||
|
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<p class="stat-label">API 이용 건수</p>
|
<p class="stat-label">API 이용 건수(지난달)</p>
|
||||||
<div class="stat-icon">
|
<div class="stat-icon">
|
||||||
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
<rect width="70" height="70" fill="url(#pattern0_3001_94)"/>
|
<rect width="70" height="70" fill="url(#pattern0_3001_94)"/>
|
||||||
@@ -318,6 +320,8 @@
|
|||||||
<!-- 지금 바로 회원가입 CTA 섹션 -->
|
<!-- 지금 바로 회원가입 CTA 섹션 -->
|
||||||
<section class="signup-cta-section">
|
<section class="signup-cta-section">
|
||||||
<div class="cta-background"></div>
|
<div class="cta-background"></div>
|
||||||
|
<img th:src="@{/img/cta_character_left.png}" alt="" class="cta-character cta-character-left">
|
||||||
|
<img th:src="@{/img/cta_character_right.png}" alt="" class="cta-character cta-character-right">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="cta-content">
|
<div class="cta-content">
|
||||||
<h2 class="cta-title">DJBank API를 지금 바로 만나보세요.</h2>
|
<h2 class="cta-title">DJBank API를 지금 바로 만나보세요.</h2>
|
||||||
|
|||||||
@@ -59,14 +59,6 @@
|
|||||||
<!-- 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>
|
||||||
@@ -80,15 +72,8 @@
|
|||||||
|
|
||||||
<!-- Client Secret -->
|
<!-- Client Secret -->
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<label class="info-label">
|
<label class="info-label">Client Secret</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">
|
||||||
|
|
||||||
<!-- secret이 아직 DB에 존재: 최초 1회 조회 가능 -->
|
|
||||||
<th:block th:if="${secretAvailable}">
|
|
||||||
<!-- View Button (shown initially) -->
|
<!-- View Button (shown initially) -->
|
||||||
<button type="button" class="btn-view-secret" id="hiddenSecretBox" onclick="showPasswordPrompt()">
|
<button type="button" class="btn-view-secret" id="hiddenSecretBox" onclick="showPasswordPrompt()">
|
||||||
<span>조회</span>
|
<span>조회</span>
|
||||||
@@ -97,30 +82,17 @@
|
|||||||
<path d="m21 21-4.35-4.35"></path>
|
<path d="m21 21-4.35-4.35"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<!-- Actual Secret (hidden initially, 값은 서버 응답으로 JS가 주입) -->
|
<!-- Actual Secret (hidden initially) -->
|
||||||
<div id="revealedSecretBox" style="display: none;">
|
<div id="revealedSecretBox" style="display: none;">
|
||||||
<div class="info-row info-row-with-action" style="margin-bottom: 0;">
|
<div class="info-row info-row-with-action" style="margin-bottom: 0;">
|
||||||
<div class="info-value" style="display: flex; gap: 16px; align-items: center;">
|
<div class="info-value" style="display: flex; gap: 16px; align-items: center;">
|
||||||
<div class="info-value-box with-copy" id="revealedSecretValue"></div>
|
<div class="info-value-box with-copy" th:text="${apiKey.clientsecret}">secret-key-67890</div>
|
||||||
<button type="button" class="btn-copy-action" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)">
|
<button type="button" class="btn-copy-action" th:data-secret="${apiKey.clientsecret}" onclick="copyToClipboardFromButton(this)">
|
||||||
+ 복사
|
+ 복사
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
@@ -206,50 +178,36 @@
|
|||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
/*<![CDATA[*/
|
/*<![CDATA[*/
|
||||||
|
|
||||||
var CREDENTIAL_CLIENT_ID = /*[[${apiKey.clientid}]]*/ '';
|
// Show password prompt for viewing client secret
|
||||||
|
|
||||||
// 비밀정보 안내(분실 시 조치) 가이드 팝업
|
|
||||||
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: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
|
message: '보안을 위해 비밀번호를 입력해주세요.',
|
||||||
onConfirm: function(password) {
|
onConfirm: function(password) {
|
||||||
|
// Verify password via AJAX
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
|
url: /*[[@{/myapikey/verify-password}]]*/ '/myapikey/verify-password',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
contentType: 'application/json',
|
data: {password: password},
|
||||||
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
|
|
||||||
headers: {
|
headers: {
|
||||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||||
}
|
},
|
||||||
}).done(function(response) {
|
success: function(response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
// Hide password popup
|
||||||
customPopups.hidePasswordInput();
|
customPopups.hidePasswordInput();
|
||||||
|
|
||||||
// 서버가 반환한 secret을 화면에 1회 주입
|
// Reveal the client secret
|
||||||
$('#revealedSecretValue').text(response.secret);
|
|
||||||
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
|
||||||
|
|
||||||
$('#hiddenSecretBox').hide();
|
$('#hiddenSecretBox').hide();
|
||||||
$('#revealedSecretBox').fadeIn(300);
|
$('#revealedSecretBox').fadeIn(300);
|
||||||
} else if (response.alreadyRevealed) {
|
|
||||||
customPopups.hidePasswordInput();
|
|
||||||
showLostKeyGuide();
|
|
||||||
} else {
|
} else {
|
||||||
|
// Show error message
|
||||||
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
||||||
}
|
}
|
||||||
}).fail(function() {
|
},
|
||||||
|
error: function() {
|
||||||
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onCancel: function() {
|
onCancel: function() {
|
||||||
@@ -294,8 +252,7 @@
|
|||||||
function deleteApiKeyFromButton(button) {
|
function deleteApiKeyFromButton(button) {
|
||||||
var clientId = $(button).data('client-id');
|
var clientId = $(button).data('client-id');
|
||||||
|
|
||||||
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function(confirmed) {
|
if (!confirm('정말로 이 인증키를 삭제하시겠습니까?')) {
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,19 +271,13 @@
|
|||||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||||
}
|
}
|
||||||
}).done(function(response) {
|
}).done(function(response) {
|
||||||
if (response && response.success === false) {
|
alert(response.msg || 'API Key가 삭제되었습니다.');
|
||||||
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function() {
|
|
||||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||||
});
|
|
||||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
alert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||||
}).always(function() {
|
}).always(function() {
|
||||||
$('.loading-overlay').hide();
|
$('.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/... · X-AUTH-TOKEN: Bearer <access_token></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>
|
||||||
<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">GET · POST</span>
|
<span class="oauth2-2legged__method">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--required">필수</span></td>
|
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--optional">선택</span></td>
|
||||||
<td>고정값 "api"</td>
|
<td>호출 권한 범위 (공백 구분)</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=api'</span>
|
-d <span class="o2leg-c">'scope=read.accounts'</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">86399</span>,
|
<span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86400</span>,
|
||||||
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"api"</span>,
|
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"read.accounts"</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>부여된 권한 범위 (api)</td>
|
<td>실제 부여된 권한 범위</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">X-AUTH-TOKEN 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p>
|
<p class="oauth2-2legged__desc">Authorization 헤더에 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">X-AUTH-TOKEN:</code>
|
<code class="oauth2-2legged__header-key">Authorization:</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>401 "Access token expired" 응답 시 재발급 후 재호출</li>
|
<li>권한이 다른 API 는 scope 별로 토큰을 분리 발급</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">'X-AUTH-TOKEN: Bearer eyJhbGciOiJSUzI..'</span> \
|
-H <span class="o2leg-c">'Authorization: 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,52 +313,28 @@ 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">401</span></td>
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--400">400</span></td>
|
||||||
<td><code>E.GW.AUTH_FAIL</code></td>
|
<td><code>invalid_request</code></td>
|
||||||
<td>client not found (client_id 오류)</td>
|
<td>필수 파라미터 누락/형식 오류</td>
|
||||||
<td>client_id 확인</td>
|
<td>grant_type, client_id, 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>E.GW.AUTH_FAIL</code></td>
|
<td><code>invalid_client</code></td>
|
||||||
<td>Bad client credentials (client_secret 불일치)</td>
|
<td>자격증명 불일치 / 미승인</td>
|
||||||
<td>client_secret 확인</td>
|
<td>ClientID·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>E.GW.AUTH_FAIL</code></td>
|
<td><code>invalid_token</code></td>
|
||||||
<td>Include unacceptable scope (허용되지 않은 scope)</td>
|
<td>만료/위·변조된 토큰</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">400</span></td>
|
<td><span class="oauth2-2legged__status-badge oauth2-2legged__status-badge--403">403</span></td>
|
||||||
<td><code>E.GW.AUTH_FAIL</code></td>
|
<td><code>insufficient_scope</code></td>
|
||||||
<td>Invalid Mandatory Field {grant_type} (누락)</td>
|
<td>scope 권한 부족</td>
|
||||||
<td>grant_type 포함 여부 확인</td>
|
<td>필요 scope 추가 신청 후 재발급</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>
|
||||||
@@ -375,9 +351,11 @@ 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">"E.GW.AUTH_FAIL"</span>,
|
<span class="o2leg-c">"error"</span>: <span class="o2leg-y">"invalid_client"</span>,
|
||||||
<span class="o2leg-c">"error_description"</span>:
|
<span class="o2leg-c">"error_description"</span>:
|
||||||
<span class="o2leg-y">"Unauthorized. [Bad client credentials(Client Secret is not match)]"</span>
|
<span class="o2leg-y">"Client authentication failed"</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>
|
||||||
@@ -397,16 +375,26 @@ 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>에러 코드 (예: E.GW.AUTH_FAIL)</td>
|
<td>에러 코드 (예: invalid_client)</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 코드는 게이트웨이 표준 코드 (E.GW.AUTH_FAIL)</p>
|
<p class="oauth2-2legged__error-note">⚠ error 코드는 RFC 6749 표준 코드 또는 DJBank 확장 코드</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -7,23 +7,6 @@
|
|||||||
<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,6 +3,16 @@
|
|||||||
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">
|
||||||
@@ -218,6 +228,7 @@
|
|||||||
<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>
|
||||||
@@ -493,6 +504,107 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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>
|
||||||
|
|||||||