19 Commits

Author SHA1 Message Date
Rinjae 4395757d98 사용자 최근 작성 글 조회 기능 추가:
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- 최근 3건 조회 서비스/페이사드/리포지토리 구현
- HTML 아코디언 UI 및 스타일 추가
2026-07-13 17:27:06 +09:00
Rinjae 778d579ada # Conflicts:
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
#	src/main/resources/static/css/main.css.map
2026-07-13 13:29:54 +09:00
Rinjae 3e403788e5 비운영 환경 Hibernate SQL 로그 비활성화:
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- show_sql, format_sql 설정 false로 수정
2026-07-13 11:50:01 +09:00
Rinjae 6c5ba6765d 조회수 비정상 증가 방지 로직 추가
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- sessionStorage 활용 중복 증가 억제
- CSRF 토큰 기반 조회수 증가 API 호출 처리
2026-07-13 11:37:06 +09:00
Rinjae a97378b84d Q&A 문의 공개범위 기능 추가
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- 공개범위 설정 기능 개발(ALL/ORG/PRIVATE) - 게시물/댓글 정교화
- 요청 및 DB 처리 확장 - UI, DTO, Service 계층 업데이트
- 감사 로그 및 조회수 처리 로직 추가
2026-07-13 11:32:34 +09:00
Rinjae 9c01db9d00 weblogic.xml 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- XML 태그 단순화 및 불필요한 속성 제거
- session-descriptor에 cookie-name 추가
2026-07-10 17:48:34 +09:00
Rinjae 08bd705dec 세션 유지 기능 추가 및 FAQ 관리 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 비운영 환경 + DB 설정 활성 시 세션 유지(타임아웃 무시) 지원
- FAQ 단건 조회 API 추가 (`FaqFacade`, `FaqFacadeImpl`)
- 헤더 세션 타이머 UI 개선 및 유지 옵션 체크박스 추가
2026-07-10 16:36:19 +09:00
Rinjae 99f765507b 세션 유지 기능 추가 및 FAQ 관리 개선:
- 비운영 환경 + DB 설정 활성 시 세션 유지(타임아웃 무시) 지원
- FAQ 단건 조회 API 추가 (`FaqFacade`, `FaqFacadeImpl`)
- 헤더 세션 타이머 UI 개선 및 유지 옵션 체크박스 추가
2026-07-10 16:36:11 +09:00
Rinjae 7d6aabe122 문의 상태값 'REVIEWING' 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 상태 코드/배지 지원 로직(DjbInquiryStatus) 반영
- Inquiry 댓글 작성자명 관리자 구분 로직 수정
2026-07-10 14:36:23 +09:00
Rinjae f08da86eb3 - Jenkinsfile 경로 수정: WL_HOME, WL_DEPLOY_DIR 디렉토리 변경
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- credentialDetail.html: 비밀값 1회 표시 경고 메시지 추가
2026-07-09 15:26:01 +09:00
Rinjae 2a2505d5f7 관리자 게이트웨이 연동 및 보안 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- AdminGatewayClient 클래스 추가: GW 인증 차단/리로드 처리
- Client Secret 1회 노출+삭제 로직(AppServiceFacade, MyAppController) 추가
- credentialDetail.html에 비밀정보 분실 대응 UI 및 JS 로직 구현
2026-07-08 18:34:33 +09:00
Rinjae cad574324e Jenkinsfile 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- skipDefaultCheckout 옵션 추가 (Deploy 노드 Git 접근 불필요)
2026-07-06 21:05:00 +09:00
Rinjae 4b4ef4196e Jenkinsfile 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- eapim-portal.war 해시 생성 방식 단순화 (sha256만 유지)
- archiveArtifacts 대상 및 스탠징 파일 축소
2026-07-06 20:49:10 +09:00
Rinjae 5402a5354a Jenkinsfile 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 빌드 및 배포 환경을 WebLogic으로 변경
- 다단계 파이프라인 구성 정비 및 에이전트 설정 추가
2026-07-06 20:42:14 +09:00
Rinjae c5b23c3873 PortalProperty 부팅 시 중복키 점검 기능 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- (PROPERTY_GROUP_NAME, PROPERTY_NAME) 유일성 검증
- 중복 발견 시 ERROR 로그 출력 및 수동 정리 안내
- JSESSIONID 쿠키 이름 변경 (PORTAL_JSESSIONID → JSESSIONID_PORTAL)
2026-07-06 17:47:48 +09:00
Rinjae 83d267faed 클라이언트 하드닝 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- DevTools 감지 및 화면 삭제, 우클릭 차단 기능 구현
- PortalProperty 기반 설정 관리 서비스 추가
- 클라이언트 가드 스크립트 및 토글 로직 템플릿 연동
2026-07-06 15:32:18 +09:00
Rinjae b02e598d62 수수료 관리 기능 제거:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 관련 Controller, DTO 클래스 삭제
- commissionManage.html 및 스타일 파일 제거
2026-07-03 18:30:34 +09:00
Rinjae 4f9fbefbcc OAuth2 가이드 문서 업데이트:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 인증 헤더 `X-AUTH-TOKEN`으로 변경
- `scope` 필수값 "api"로 수정
- 오류 코드 및 설명 표준화 (`E.GW.AUTH_FAIL`)
2026-07-01 15:58:21 +09:00
Rinjae c403118289 - StartupInfoPrinter 클래스 추가 - 서비스 시작 시 배너 출력
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- MessageCode 채널 필드 @Deprecated 추가 및 기본값 설정
- MessageCode 신규 생성자 추가 - channel 파라미터 제거
2026-07-01 10:37:08 +09:00
136 changed files with 5282 additions and 12057 deletions
+124 -185
View File
@@ -1,220 +1,159 @@
pipeline {
agent { label 'djb-vm' }
agent none
options {
timestamps()
disableConcurrentBuilds()
skipDefaultCheckout() // stage별 agent의 자동 SCM checkout 방지 (Deploy 노드는 git 접근 불필요, unstash로만 WAR 수신)
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
environment {
JAVA_HOME = '/apps/opts/jdk8'
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home'
NODE_HOME = '/apps/opts/node-v24'
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
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 {
stage('Checkout') {
steps {
checkout scm
stage('Build (djb-vm)') {
agent { label 'djb-vm' }
environment {
JAVA_HOME = '/apps/opts/jdk8'
GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home'
NODE_HOME = '/apps/opts/node-v24'
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
}
}
stage('Checkout dependencies') {
steps {
sh '''
set -eu
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
rm -rf elink-portal-common
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
elink-portal-common
else
git -C elink-portal-common fetch --depth=1 origin master
git -C elink-portal-common reset --hard origin/master
git -C elink-portal-common clean -fdx
fi
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
else
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
git -C eapim-online/elink-online-core-jpa clean -fdx
fi
'''
}
}
stage('Verify toolchain') {
steps {
sh '''
set -eu
java -version
gradle --version
'''
}
}
stage('Test') {
steps {
sh 'gradle clean test --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Checkout dependencies') {
steps {
sh '''
set -eu
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
rm -rf elink-portal-common
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git elink-portal-common
else
git -C elink-portal-common fetch --depth=1 origin master
git -C elink-portal-common reset --hard origin/master
git -C elink-portal-common clean -fdx
fi
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git eapim-online/elink-online-core-jpa
else
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
git -C eapim-online/elink-online-core-jpa clean -fdx
fi
'''
}
}
stage('Verify toolchain') {
steps { sh 'set -eu; java -version; gradle --version' }
}
stage('Test') {
steps { sh 'gradle clean test --no-daemon -Pprofile=weblogic' }
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
}
}
}
stage('Build WAR') {
steps { sh 'gradle build -x test --no-daemon -Pprofile=weblogic' }
post {
success {
sh '''
set -eu
cd build/libs
sha256sum eapim-portal.war > eapim-portal.war.sha256
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
stash name: 'war', includes: 'build/libs/eapim-portal.war'
}
}
}
}
}
stage('Build WAR') {
steps {
sh 'gradle build -x test --no-daemon'
stage('Deploy (weblogic)') {
agent { label 'weblogic' }
environment {
JENKINS_NODE_COOKIE = 'dontKillMe' // background weblogic를 빌드 종료 시 죽이지 않게
WL_HOME = '/app/eapim/devportal'
WL_DEPLOY_DIR = '/app/eapim/devportal'
WL_WAR_NAME = 'eapim-portal.war'
WL_HTTP_PORT = '39130'
WL_NOHUP = '/logs/weblogic/domains/eapimDomain/nohup.devSvr11.out'
}
post {
success {
sh '''
set -eu
cd build/libs
for f in eapim-portal.war eapim-portal-boot.war; do
sha1sum "$f" > "$f.sha1"
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
stages {
stage('Stop WebLogic') {
steps {
sh '"$WL_HOME/stopDev11.sh"' // 동기: 완전 종료까지 블록, 미기동 시에도 안전
}
}
}
}
stage('Deploy WAR') {
steps {
unstash 'war'
sh '''
set -eu
cp build/libs/eapim-portal.war "$WL_DEPLOY_DIR/$WL_WAR_NAME"
ls -la "$WL_DEPLOY_DIR"
'''
}
}
stage('Start WebLogic and readiness') {
steps {
sh '''
set -eu
"$WL_HOME/startDev11.sh" # nohup & 로 백그라운드 기동, 즉시 리턴
stage('Stop Tomcat') {
steps {
sh '''
set +e
systemctl --user stop eapim-portal 2>/dev/null
DEADLINE=$(($(date +%s) + 300))
STATUS=000
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
"http://localhost:$WL_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
[ "$STATUS" = "200" ] && { echo "Readiness OK (/health/ready)"; break; }
sleep 3
done
if [ -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('Deploy ROOT.war') {
steps {
sh '''
set -eu
DEPLOY_DIR="$CATALINA_BASE/webapps"
rm -rf "$DEPLOY_DIR/ROOT" "$DEPLOY_DIR/ROOT.war"
cp build/libs/eapim-portal.war "$DEPLOY_DIR/ROOT.war"
ls -la "$DEPLOY_DIR"
'''
}
}
stage('Start Tomcat and readiness') {
steps {
sh '''
set -eu
LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
BEFORE=0
[ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
systemctl --user start eapim-portal
DEADLINE=$(($(date +%s) + 120))
LIVE_OK=0
STATUS=000
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
if [ "$LIVE_OK" = "0" ]; then
LIVE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
"http://localhost:$DEPLOY_HTTP_PORT/health" 2>/dev/null || echo "000")
if [ "$LIVE" = "200" ]; then
echo "Liveness OK (/health)"
LIVE_OK=1
if [ "$STATUS" != "200" ]; then
echo "Readiness failed within 300s, last HTTP=$STATUS"
[ -f "$WL_NOHUP" ] && tail -120 "$WL_NOHUP"
exit 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
if [ "$STATUS" != "200" ]; then
echo "Readiness probe failed within 120s, last HTTP status: $STATUS"
[ -f /tmp/eapim-ready-body.json ] && cat /tmp/eapim-ready-body.json && echo
[ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | tail -100
exit 1
fi
echo "--- key boot log lines ---"
tail -c +$((BEFORE+1)) "$LOG" | grep -E "Server startup|Deployment of web" | head -10 || true
'''
'''
}
}
}
}
}
post {
success {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS","attachments":[{"color":"good","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"SUCCESS","short":true}]}]}' \
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
curl -sS --fail --max-time 5 \
-H 'Content-Type: application/json' \
-X POST \
--data "$payload" \
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for SUCCESS"
'''
node('weblogic') {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
'''
}
}
failure {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) FAILURE","attachments":[{"color":"danger","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"FAILURE","short":true}]}]}' \
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
curl -sS --fail --max-time 5 \
-H 'Content-Type: application/json' \
-X POST \
--data "$payload" \
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for FAILURE"
'''
node('weblogic') {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) FAILURE"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
'''
}
}
}
}
+6 -3
View File
@@ -30,9 +30,12 @@ dependencies {
implementation 'com.eactive.elink.common:elink-common-data:4.5.5'
// implementation 'io.swagger.core.v3:swagger-core:2.2.25'
// implementation 'io.swagger.parser.v3:swagger-parser:2.1.23'
implementation fileTree(dir: 'libs/swagger', include: ['*.jar'])
// OpenAPI 3.0/3.1 spec 지원 (Nexus/Maven 해석). 기존 libs/swagger fileTree 대체.
// swagger 최신 릴리스(2.2.52/2.1.45)도 SpecVersion 은 V30/V31 까지만 — OpenAPI 3.2 는
// 아직 swagger-core/parser 어느 버전도 미지원. 지원 추가 시 버전만 상향하면 됨. JDK8 호환.
implementation 'io.swagger.core.v3:swagger-core:2.2.52' // io.swagger.v3.oas.models.*, io.swagger.v3.core.util.Json
implementation 'io.swagger.parser.v3:swagger-parser:2.1.45' // io.swagger.parser.OpenAPIParser, io.swagger.v3.parser.*
implementation 'io.swagger:swagger-annotations:1.6.14' // io.swagger.annotations.ApiModelProperty (CommissionSearch)
implementation project(':elink-online-core-jpa')
implementation project(':elink-portal-common')
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,23 @@
-- =============================================================================
-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
--
-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
--
-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
-- =============================================================================
ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
COMMIT;
@@ -2,15 +2,15 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.FileCopyUtils;
@@ -22,35 +22,45 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/apis")
@RequiredArgsConstructor
@Slf4j
public class TestbedSpecController {
@Autowired
private ApiSpecInfoService apiSpecInfoService;
private final ApiSpecInfoService apiSpecInfoService;
private final DjbTestbedSpecServerRewriter serverRewriter;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> getSwagger(@PathVariable String id) {
public ResponseEntity<String> getSwagger(@PathVariable String id, HttpServletRequest request) {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> getSwaggerYaml(@PathVariable String id, HttpServletRequest request) {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
try {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
return ResponseEntity.ok().body(content);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
} catch (IOException e) {
log.error("Failed to read default token api spec file", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
return null;
}
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
if (!spec.isPresent()) {
StringUtils.hasText(spec.get().getTestbedSpec());
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null;
}
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.IOException;
@@ -20,6 +21,20 @@ public class APISender {
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
private final DjbTestbedGatewayProperty gatewayProperty;
public APISender(DjbTestbedGatewayProperty gatewayProperty) {
this.gatewayProperty = gatewayProperty;
}
/** connect/read 타임아웃 적용. 반드시 connect(getOutputStream/getResponseCode) 이전에 호출. */
private void applyTimeout(HttpURLConnection connection) {
int t = gatewayProperty.timeoutMillis();
connection.setConnectTimeout(t);
connection.setReadTimeout(t);
}
public String requestPost(String uri, String requestBody) throws IOException {
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
@@ -58,6 +73,7 @@ public class APISender {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "application/json");
for (Map.Entry<String, String> entry : headers.entrySet()) {
@@ -82,6 +98,7 @@ public class APISender {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("POST");
for (Map.Entry<String, String> entry : headers.entrySet()) {
@@ -125,10 +142,11 @@ public class APISender {
return response.toString();
}
private static HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
URL endpoint = new URL(uri);
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
@@ -4,6 +4,8 @@ package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.io.BufferedReader;
import java.io.IOException;
import java.net.URI;
@@ -20,6 +22,7 @@ import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -65,7 +68,23 @@ public class ApiTesterFilter implements Filter {
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url");
if (url.contains("/api/v1/oauth/token")){
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) {
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
return;
}
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
// 반환하기 위해 try 로 감싼다.
try {
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath());
if (tokenRequest) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
@@ -74,82 +93,174 @@ public class ApiTesterFilter implements Filter {
}
String body = sb.toString();
// Parse request parameters
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
for (String pair : pairs) {
String[] keyValue = pair.split("=");
if (keyValue.length == 2) {
params.put(keyValue[0], keyValue[1]);
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
for (String pair : pairs) {
String[] keyValue = pair.split("=");
if (keyValue.length == 2) {
params.put(keyValue[0], keyValue[1]);
}
}
String scope = params.getOrDefault("scope", "default");
String token = "{\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" +
" \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" +
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
"}";
response.setContentType("application/json");
response.getWriter().println(token);
} else {
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/x-www-form-urlencoded");
headers.put("Accept", "application/json");
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
response.setContentType("application/json");
response.getWriter().println(tokenResponse);
}
String scope = params.getOrDefault("scope", "default");
String token = "{\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" +
" \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" +
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
"}";
response.setContentType("application/json");
response.getWriter().println(token);
} else {
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) {
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
return;
}
String responseType = apiSpecInfoDto.getResponseType();
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
return;
}
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
// - mock : ApiSpecInfo.mockUrl (기존 동작)
boolean gw = "gw".equalsIgnoreCase(responseType);
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
}
headers.remove("original-url");
headers.remove("original-api-id");
String targetUri;
Map<String, String[]> paramMap;
if (gw) {
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
headers.remove("host");
headers.remove("Host");
targetUri = url;
paramMap = new HashMap<>();
} else {
String mockUrl = apiSpecInfoDto.getMockUrl();
String method = apiSpecInfoDto.getApiMethod();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
targetUri = apiSpecInfoDto.getMockUrl();
paramMap = extractQueryParams(url);
}
Map<String, String> headers = new HashMap<>();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
} catch (java.net.SocketTimeoutException e) {
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (IOException e) {
// 연결 실패 등 네트워크 오류
logger.error("테스트베드 프록시 호출 실패", e);
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (Exception e) {
// 그 외 예기치 못한 오류도 JSON 으로 반환
logger.error("테스트베드 프록시 처리 오류", e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
}
}
/** 요청 본문 전체를 문자열로 읽는다. */
private String readBody(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
private Map<String, String[]> extractQueryParams(String originalUrl) {
Map<String, String[]> paramMap = new HashMap<>();
String[] parts = originalUrl.split("\\?");
if (parts.length > 1) {
for (String param : parts[1].split("&")) {
String[] kv = param.split("=");
if (kv.length > 1) {
paramMap.put(kv[0], new String[]{kv[1]});
}
Map<String, String[]> paramMap = new HashMap<>();
String originalUrl = headers.get("original-url");
//sprlit original url with ? get the second part then split with & put into paramMap
String[] originalUrlArr = originalUrl.split("\\?");
if (originalUrlArr.length > 1) {
String[] paramArr = originalUrlArr[1].split("&");
for (String param : paramArr) {
String[] paramKeyValue = param.split("=");
if (paramKeyValue.length > 1) {
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
}
}
}
headers.remove("original-url");
headers.remove("original-api-id");
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr = "";
if (method.equalsIgnoreCase("post")) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
} else {
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
}
return paramMap;
}
/** 상태코드 + JSON 본문 응답. */
private void writeJson(ServletResponse response, int status, String json) throws IOException {
((HttpServletResponse) response).setStatus(status);
response.setContentType("application/json");
response.getWriter().println(json);
}
/** JSON 문자열 값에 넣기 위한 최소 escape (예외 메시지 등). */
private String escapeJson(String s) {
if (s == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '\\': sb.append("\\\\"); break;
case '"': sb.append("\\\""); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
return sb.toString();
}
@Override
@@ -10,6 +10,7 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
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.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper;
@@ -21,6 +22,7 @@ import java.util.Map;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -44,6 +46,7 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Slf4j
@Controller
@RequestMapping("/myapikey")
@RequiredArgsConstructor
@@ -77,6 +80,7 @@ public class MyAppController {
private final ApiService apiService;
private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient;
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
@@ -168,7 +172,13 @@ public class MyAppController {
}
});
// Client Secret은 초기 HTML에 담지 않는다. (비번 확인 후 서버가 1회만 반환)
boolean secretAvailable = StringUtils.isNotBlank(apiKey.getClientsecret());
apiKey.setClientsecret(null);
model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("authType", "OAuth2");
return new ModelAndView(CREDENTIAL_DETAIL);
}
@@ -228,28 +238,45 @@ public class MyAppController {
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
Map<String, Object> result = new java.util.HashMap<>();
try {
String clientId = requestData.get("clientId");
String type = requestData.get("type");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
result.put("msg", "클라이언트 ID가 필요합니다.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
// API Key 즉시 삭제 (승인 프로세스 없이)
appServiceFacade.deleteApp(user.getPortalOrg().getId(), clientId);
result.put("success", true);
result.put("msg", "API Key가 삭제되었습니다.");
} catch (Exception e) {
String clientId = requestData.get("clientId");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
result.put("msg", "클라이언트 ID가 필요합니다.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String orgId = user.getPortalOrg().getId();
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
result.put("success", false);
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
return result;
}
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
try {
adminGatewayClient.blockClient(clientId);
} catch (Exception e) {
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
result.put("success", false);
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
return result;
}
// 3. GW 차단 성공 시에만 포털 credential 삭제
try {
appServiceFacade.deleteApp(orgId, clientId);
} catch (Exception e) {
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
result.put("success", false);
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
return result;
}
result.put("success", true);
result.put("msg", "API Key가 삭제되었습니다.");
return result;
}
@@ -282,6 +309,57 @@ public class MyAppController {
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 요청 이력을 페이지 형태로 조회합니다.
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.app.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
/**
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
*
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AdminGatewayClient {
private static final String PROP_GROUP = "Portal";
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
private final RestTemplate restTemplate;
private final PortalPropertyService portalPropertyService;
/**
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
*
* @param clientId 차단할 클라이언트 ID
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
*/
public void blockClient(String clientId) {
String baseUrl = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
Map<?, ?> body = response.getBody();
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
if (!success) {
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
}
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
}
}
@@ -293,6 +293,29 @@ public class AppServiceFacade {
}
}
/**
* Client Secret을 1회 조회하고 그 즉시 DB(PTL_CREDENTIAL)에서 물리 삭제합니다.
* 보안 정책상 비밀정보는 최초 1회만 제공되며, 조회 후에는 복구할 수 없습니다.
*
* @param orgId 조직 ID (소유권 확인용)
* @param clientId 대상 클라이언트 ID
* @return 삭제 전 Client Secret 값. 이미 노출되어 비어있으면 {@code null}
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
*/
public String revealAndDeleteClientSecret(String orgId, String clientId) {
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
String secret = credential.getClientsecret();
if (StringUtils.isBlank(secret)) {
return null; // 이미 1회 노출되어 삭제됨
}
credential.setClientsecret(null); // 물리 삭제 (1회 제공)
credentialRepository.save(credential);
return secret;
}
/**
* API Key(Credential)를 즉시 삭제합니다.
* 승인 프로세스 없이 바로 삭제 처리됩니다.
@@ -1,26 +0,0 @@
package com.eactive.apim.portal.apps.commission.constant;
/**
* Application constants.
*/
public final class Constants {
public static final String SYSTEM_ACCOUNT = "system";
public static final String APIM_FOR_OBP_KEY = "x-obp-trust-system";
public static final String APIM_CODE_FOR_OBP_KEY = "x-obp-partnercode";
public static final String SMS_URL = "/api/messaging/sms";
public static final String COMMISSION_URL = "/api/billing/billing/findBillingForCondition";
public static final String COMMISSION_PRINT_URL = "/api/billing/billing/findBillingForCondition/print";
public static final String OBP_ORGANIZATION_URL = "/api/customer/findCustomerByBusinessManRegistrationNo/";
public static String LANGUAGE = "ko";
private Constants() {
}
}
@@ -1,320 +0,0 @@
package com.eactive.apim.portal.apps.commission.controller;
import com.eactive.apim.portal.apps.commission.constant.Constants;
import com.eactive.apim.portal.apps.commission.dto.CommissionDTO;
import com.eactive.apim.portal.apps.commission.dto.CommissionSearch;
import com.eactive.apim.portal.apps.commission.exception.ErrorUtil;
import com.eactive.apim.portal.apps.commission.service.ExternalService;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections.MapUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping(value = "/commission")
@Secured("ROLE_APP")
@RequiredArgsConstructor
public class CommissionController {
private final Logger log = LoggerFactory.getLogger(CommissionController.class);
private final ExternalService externalService;
private final PortalPropertyService portalPropertyService;
private final ObjectMapper objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
@GetMapping("/manage")
public String commissionManagePage(Model model) {
// 기본 정보 설정
model.addAttribute("organization", SecurityUtil.getUserOrg());
model.addAttribute("noOrgCode", StringUtils.isEmpty(SecurityUtil.getUserOrg().getOrgCode()));
return "apps/commission/commissionManage";
}
@GetMapping("/print/{year}/{month}")
public String commissionPrintPage(
@PathVariable("year") String year,
@PathVariable("month") String month,
Model model) {
CommissionSearch search = new CommissionSearch();
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
search.setYear(year);
search.setMonth(month);
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
if (result.hasError()) {
model.addAttribute("error", result.getError());
return "apps/commission/commissionPrint";
}
// Model에 데이터 바인딩
model.addAttribute("commission", result.getData());
model.addAttribute("organization", SecurityUtil.getUserOrg().getOrgName());
model.addAttribute("year", year);
model.addAttribute("month", month);
model.addAttribute("toDay", java.time.LocalDate.now().toString());
return "apps/commission/commissionPrint";
}
// ==================== API Endpoints ====================
@PostMapping("/print/check")
public ResponseEntity<?> checkPrintData(@RequestBody CommissionSearch search) {
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
if (StringUtils.isEmpty(search.getPartnerCode())) {
return ErrorUtil.create("fail.commission.needPartnerCode");
}
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
if (result.hasError()) {
return ErrorUtil.create("fail.commission.print", result.getError());
}
return ResponseEntity.ok().body(result.getData());
}
@PostMapping()
public ResponseEntity<?> list(@RequestBody CommissionSearch search) {
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
if (StringUtils.isEmpty(search.getPartnerCode())) {
return ErrorUtil.create("fail.commission.needPartnerCode");
}
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_URL);
if (result.hasError()) {
return ErrorUtil.create("fail.commission.list", result.getError());
}
return ResponseEntity.ok().body(result.getData());
}
// ==================== Private Methods ====================
/**
* 외부 API를 호출하여 수수료 데이터를 조회합니다.
*
* @param search 검색 조건
* @param apiPath API 경로 (Constants.COMMISSION_URL 또는 Constants.COMMISSION_PRINT_URL)
* @return 조회 결과
*/
private CommissionResult fetchCommissionData(CommissionSearch search, String apiPath) {
if (StringUtils.isEmpty(search.getPartnerCode())) {
return CommissionResult.error("파트너 코드가 없습니다.");
}
try {
String obmUrl = portalPropertyService.getPortalPropertiesAsMap("Portal").getOrDefault("obp.obm.url", "");
String url = obmUrl + apiPath;
// 첫 번째 API 호출
String result = externalService.getResponseHttpPost(url, toJson(search));
if (StringUtils.isEmpty(result)) {
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
}
HashMap<String, Object> returnObj = objectMapper.readValue(result, HashMap.class);
// 에러 응답 체크
String errorMessage = extractErrorMessage(returnObj);
if (errorMessage != null) {
return CommissionResult.error(errorMessage);
}
// Together 관련 추가 처리 (000002-01)
if (StringUtils.pathEquals(search.getPartnerCode(), "000002-01")) {
log.debug("Found Together : " + search.getPartnerCode());
CommissionSearch togetherSearch = new CommissionSearch();
togetherSearch.setPartnerCode("000002-02");
togetherSearch.setYear(search.getYear());
togetherSearch.setMonth(search.getMonth());
String togetherResult = externalService.getResponseHttpPost(url, toJson(togetherSearch));
if (StringUtils.isEmpty(togetherResult)) {
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
}
HashMap<String, Object> togetherReturnObj = objectMapper.readValue(togetherResult, HashMap.class);
log.debug("togetherLendingReturnObj : " + togetherReturnObj.toString());
String togetherErrorMessage = extractErrorMessage(togetherReturnObj);
if (togetherErrorMessage != null) {
return CommissionResult.error(togetherErrorMessage);
}
HashMap<String, Object> mergeMap = mergeCommissionData(returnObj, togetherReturnObj);
result = objectMapper.writeValueAsString(mergeMap);
}
CommissionDTO commissionDTO = objectMapper.readValue(result, CommissionDTO.class);
return CommissionResult.success(commissionDTO);
} catch (Exception e) {
log.warn(e.getMessage(), e);
return CommissionResult.error("수수료 조회에 실패하였습니다.");
}
}
/**
* 응답에서 에러 메시지를 추출합니다.
*/
private String extractErrorMessage(HashMap<String, Object> response) {
if (!StringUtils.isEmpty(response.get("error"))) {
HashMap<String, Object> error = (HashMap) response.get("error");
return error.get("message").toString();
}
return null;
}
private String toJson(CommissionSearch search) {
try {
Map<String, String> jsonMap = new HashMap<>();
jsonMap.put("partnerCode", search.getPartnerCode());
// 월을 두 자리로 패딩 (예: 1 -> 01, 12 -> 12)
String paddedMonth = String.format("%02d", Integer.parseInt(search.getMonth()));
jsonMap.put("targetOnMonth", search.getYear() + paddedMonth);
return objectMapper.writeValueAsString(jsonMap);
} catch (Exception e) {
log.warn("Failed to serialize CommissionSearch to JSON", e);
return "{}";
}
}
/**
* Safely parse double value from HashMap
* Handles both numeric types and string representations
*/
private double safeGetDouble(HashMap<String, Object> map, String key) {
Object value = map.get(key);
if (value == null) {
return 0.0;
}
try {
if (value instanceof Number) {
return ((Number) value).doubleValue();
} else if (value instanceof String) {
return Double.parseDouble((String) value);
}
return 0.0;
} catch (NumberFormatException e) {
log.warn("Failed to parse double value for key: {} with value: {}", key, value);
return 0.0;
}
}
public HashMap<String, Object> mergeCommissionData(HashMap<String, Object> o1, HashMap<String, Object> o2) {
HashMap<String, Object> mergeMap = new HashMap<String, Object>();
String o1Status = MapUtils.getString(o1, "status");
String o2Status = MapUtils.getString(o2, "status");
String resultStatus = null;
if (Integer.parseInt(o1Status) <= Integer.parseInt(o2Status)) {
resultStatus = o1Status;
} else {
resultStatus = o2Status;
}
double apiTotalValueSum = safeGetDouble(o1, "apiTotalValueSum") + safeGetDouble(o2, "apiTotalValueSum");
double apiSum = safeGetDouble(o1, "apiSum") + safeGetDouble(o2, "apiSum");
double apiTotalValueSum2 = safeGetDouble(o1, "apiTotalValueSum2") + safeGetDouble(o2, "apiTotalValueSum2");
double apiSum2 = safeGetDouble(o1, "apiSum2") + safeGetDouble(o2, "apiSum2");
double productTotalValueCountSum = safeGetDouble(o1, "productTotalValueCountSum") + safeGetDouble(o2, "productTotalValueCountSum");
double productTotalValueAmountSum = safeGetDouble(o1, "productTotalValueAmountSum") + safeGetDouble(o2, "productTotalValueAmountSum");
double productSum = safeGetDouble(o1, "productSum") + safeGetDouble(o2, "productSum");
double productTotalValueCountSum2 = safeGetDouble(o1, "productTotalValueCountSum2") + safeGetDouble(o2, "productTotalValueCountSum2");
double productTotalValueAmountSum2 = safeGetDouble(o1, "productTotalValueAmountSum2") + safeGetDouble(o2, "productTotalValueAmountSum2");
double productSum2 = safeGetDouble(o1, "productSum2") + safeGetDouble(o2, "productSum2");
double totalValueCountSum = safeGetDouble(o1, "totalValueCountSum") + safeGetDouble(o2, "totalValueCountSum");
double totalValueCountSum2 = safeGetDouble(o1, "totalValueCountSum2") + safeGetDouble(o2, "totalValueCountSum2");
double totalValueAmountSum = safeGetDouble(o1, "totalValueAmountSum") + safeGetDouble(o2, "totalValueAmountSum");
double totalValueAmountSum2 = safeGetDouble(o1, "totalValueAmountSum2") + safeGetDouble(o2, "totalValueAmountSum2");
double sum = safeGetDouble(o1, "sum") + safeGetDouble(o2, "sum");
double sum2 = safeGetDouble(o1, "sum2") + safeGetDouble(o2, "sum2");
List<Map> o1List = (ArrayList) o1.get("list");
List<Map> o2pList = (ArrayList) o2.get("list");
o1List.addAll(o2pList);
mergeMap.put("status", resultStatus);
mergeMap.put("apiTotalValueSum", String.format("%.2f", apiTotalValueSum));
mergeMap.put("apiSum", String.format("%.2f", apiSum));
mergeMap.put("apiTotalValueSum2", String.format("%.2f", apiTotalValueSum2));
mergeMap.put("apiSum2", String.format("%.2f", apiSum2));
mergeMap.put("productTotalValueCountSum", String.format("%.2f", productTotalValueCountSum));
mergeMap.put("productTotalValueAmountSum", String.format("%.2f", productTotalValueAmountSum));
mergeMap.put("productSum", String.format("%.2f", productSum));
mergeMap.put("productTotalValueCountSum2", String.format("%.2f", productTotalValueCountSum2));
mergeMap.put("productTotalValueAmountSum2", String.format("%.2f", productTotalValueAmountSum2));
mergeMap.put("productSum2", String.format("%.2f", productSum2));
mergeMap.put("totalValueCountSum", String.format("%.2f", totalValueCountSum));
mergeMap.put("totalValueCountSum2", String.format("%.2f", totalValueCountSum2));
mergeMap.put("totalValueAmountSum", String.format("%.2f", totalValueAmountSum));
mergeMap.put("totalValueAmountSum2", String.format("%.2f", totalValueAmountSum2));
mergeMap.put("sum", String.format("%.2f", sum));
mergeMap.put("sum2", String.format("%.2f", sum2));
mergeMap.put("list", o1List);
return mergeMap;
}
// ==================== Inner Classes ====================
/**
* 수수료 조회 결과를 담는 내부 클래스
*/
private static class CommissionResult {
private CommissionDTO data;
private String error;
static CommissionResult success(CommissionDTO data) {
CommissionResult result = new CommissionResult();
result.data = data;
return result;
}
static CommissionResult error(String message) {
CommissionResult result = new CommissionResult();
result.error = message;
return result;
}
boolean hasError() {
return error != null;
}
CommissionDTO getData() {
return data;
}
String getError() {
return error;
}
}
}
@@ -1,28 +0,0 @@
package com.eactive.apim.portal.apps.commission.dto;
import java.util.List;
import lombok.Data;
@Data
public class CommissionDTO {
private String status;
private String apiTotalValueSum;
private String apiSum;
private String apiTotalValueSum2;
private String apiSum2;
private String productTotalValueCountSum;
private String productTotalValueAmountSum;
private String productSum;
private String productTotalValueCountSum2;
private String productTotalValueAmountSum2;
private String productSum2;
private String totalValueCountSum;
private String totalValueCountSum2;
private String totalValueAmountSum;
private String totalValueAmountSum2;
private String sum;
private String sum2;
private List<CommissionList> list;
private String documentFormatNo;
}
@@ -1,32 +0,0 @@
package com.eactive.apim.portal.apps.commission.dto;
import lombok.Data;
/**
* Created by jskim on 17. 01. 26.
*
* @author jskim
*/
@Data
public class CommissionList {
private String paymentTargetName;
private String paymentTargetType;
private String value;
private String minimumAmount;
private String maximumAmount;
private String totalCount;
private String totalCount2;
private String totalValue;
private String totalValue2;
private String minimumAmount2;
private String maximumAmount2;
private String paymentBaseName;
private String paymentTimingName;
private String valueDivisionName;
private String sum;
private String sum2;
private String detailFeeTypeName;
private String value2;
private String changeReason2;
}
@@ -1,17 +0,0 @@
package com.eactive.apim.portal.apps.commission.dto;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@Data
public class CommissionSearch {
@ApiModelProperty(value = "대상년")
private String year;
@ApiModelProperty(value = "대상월")
private String month;
private String partnerCode;
}
@@ -1,27 +0,0 @@
package com.eactive.apim.portal.apps.commission.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
/**
* Created by ybsong on 16. 12. 7.
*
* @author ybsong
*/
public class ErrorUtil {
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message, String description) {
return new ResponseEntity<>(new ErrorVM(message, description), status);
}
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message) {
return create(status, message, null);
}
public static ResponseEntity<ErrorVM> create(String message, String description) {
return create(HttpStatus.BAD_REQUEST, message, description);
}
public static ResponseEntity<ErrorVM> create(String message) {
return create(message, null);
}
}
@@ -1,43 +0,0 @@
package com.eactive.apim.portal.apps.commission.exception;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* View Model for transferring error message with a list of field errors.
*/
@Data
public class ErrorVM implements Serializable {
private static final long serialVersionUID = 1L;
private final String message;
private final String description;
private List<FieldErrorVM> fieldErrors;
public ErrorVM(String message) {
this(message, null);
}
public ErrorVM(String message, String description) {
this.message = message;
this.description = description;
}
public ErrorVM(String message, String description, List<FieldErrorVM> fieldErrors) {
this.message = message;
this.description = description;
this.fieldErrors = fieldErrors;
}
public void add(String objectName, String field, String message) {
if (fieldErrors == null) {
fieldErrors = new ArrayList<>();
}
fieldErrors.add(new FieldErrorVM(objectName, field, message));
}
}
@@ -1,33 +0,0 @@
package com.eactive.apim.portal.apps.commission.exception;
import java.io.Serializable;
public class FieldErrorVM implements Serializable {
private static final long serialVersionUID = 1L;
private final String objectName;
private final String field;
private final String message;
public FieldErrorVM(String dto, String field, String message) {
this.objectName = dto;
this.field = field;
this.message = message;
}
public String getObjectName() {
return objectName;
}
public String getField() {
return field;
}
public String getMessage() {
return message;
}
}
@@ -1,80 +0,0 @@
package com.eactive.apim.portal.apps.commission.service;
import com.eactive.apim.portal.apps.commission.constant.Constants;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.Map;
import javax.inject.Inject;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
/**
* 외부 API 호출 서비스
* RestTemplate 기반으로 외부 시스템과 HTTP 통신을 수행합니다.
*
* @author ybsong
* @since 2017-03-02
*/
@Service
@Transactional
@RequiredArgsConstructor
public class ExternalService {
private final Logger log = LoggerFactory.getLogger(ExternalService.class);
private final RestTemplate restTemplate;
private final PortalPropertyService portalPropertyService;
/**
* HTTP POST 요청을 통해 외부 API를 호출하고 응답을 받습니다.
*
* @param url 호출할 API URL
* @param payload 요청 본문 (JSON 문자열)
* @return API 응답 본문 (문자열), 오류 발생 시 null
*/
public String getResponseHttpPost(String url, String payload) {
try {
Map<String, String> portalProperties = portalPropertyService.getPortalPropertiesAsMap("Portal");
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set(Constants.APIM_FOR_OBP_KEY, portalProperties.getOrDefault("obp.api_key","APIMPT-0002-QVBJTVBULTAwMDI="));
headers.set(Constants.APIM_CODE_FOR_OBP_KEY, portalProperties.getOrDefault("obp.partner_code","100001-01"));
// HTTP 요청 엔티티 생성 (헤더 + 본문)
HttpEntity<String> requestEntity = new HttpEntity<>(payload, headers);
// POST 요청 실행
ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
// 응답 본문 반환
return response.getBody();
} catch (HttpStatusCodeException e) {
// 4xx, 5xx 에러더라도 응답 본문이 있으면 반환 (에러 메시지 포함)
String responseBody = e.getResponseBodyAsString();
log.warn("HTTP error from external API: url={}, status={}, body={}", url, e.getStatusCode(), responseBody);
if (responseBody != null && !responseBody.isEmpty()) {
return responseBody;
}
return null;
} catch (RestClientException e) {
log.error("Failed to call external API: url={}, error={}", url, e.getMessage(), e);
return null;
} catch (Exception e) {
log.error("Unexpected error during HTTP POST: url={}", url, e);
return null;
}
}
}
@@ -13,6 +13,7 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class FaqController {
@@ -47,4 +48,16 @@ public class FaqController {
return "apps/community/mainFaqList";
}
@GetMapping("/faq_view")
public String view(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return "redirect:/faq_list";
}
FaqDTO faq = faqFacade.getFaq(id);
model.addAttribute("faq", faq);
return "apps/community/mainFaqDetail";
}
}
@@ -7,4 +7,6 @@ import org.springframework.data.domain.Pageable;
public interface FaqFacade {
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
FaqDTO getFaq(String id);
}
@@ -34,4 +34,9 @@ public class FaqFacadeImpl implements FaqFacade {
return faqPage.map(faqMapper::map);
}
@Override
public FaqDTO getFaq(String id) {
return faqMapper.map(faqService.findById(id));
}
}
@@ -40,6 +40,7 @@ public class PartnershipApplicationController {
model.addAttribute("partnership", new PartnershipApplicationDTO());
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
model.addAttribute("recentApplications", partnershipApplicationFacade.getMyRecentApplications());
return "apps/community/mainPartnershipForm";
}
@@ -2,8 +2,10 @@ package com.eactive.apim.portal.apps.community.partnership.mapper;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
import com.eactive.apim.portal.common.mapper.CommonMapper;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
import org.springframework.stereotype.Component;
@@ -16,4 +18,8 @@ import org.springframework.stereotype.Component;
public interface PartnershipApplicationMapper {
PartnershipApplication toEntity(PartnershipApplicationDTO dto);
PartnershipApplicationSummaryDTO toSummaryDto(PartnershipApplication entity);
List<PartnershipApplicationSummaryDTO> toSummaryDtoList(List<PartnershipApplication> entities);
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
@@ -9,4 +10,10 @@ import org.springframework.stereotype.Repository;
@Repository
@EMSDataSource
public interface PartnershipApplicationRepository extends JpaRepository<PartnershipApplication, String>, JpaSpecificationExecutor<PartnershipApplication> {
/**
* 특정 작성자(createdBy)가 등록한 최근 3건을 최신순으로 조회한다.
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
*/
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
}
@@ -1,9 +1,16 @@
package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
import java.io.IOException;
import java.util.List;
public interface PartnershipApplicationFacade {
void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException;
/**
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
*/
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
}
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipApplicationMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -15,7 +16,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@@ -60,4 +63,14 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
}
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
}
@Override
public List<PartnershipApplicationSummaryDTO> getMyRecentApplications() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null) {
return Collections.emptyList();
}
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
return partnershipApplicationMapper.toSummaryDtoList(recent);
}
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -21,4 +22,12 @@ public class PartnershipApplicationService {
public void createPartnershipApplication(PartnershipApplication partnershipApplication) {
partnershipApplicationRepository.save(partnershipApplication);
}
/**
* 작성자 id 로 최근 등록 3건 조회(최신순).
*/
@Transactional(readOnly = true)
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
}
}
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@@ -16,11 +17,13 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
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.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.validation.Valid;
@@ -56,6 +59,7 @@ public class InquiryController {
model.addAttribute("inquiries", inquiries.getContent());
model.addAttribute("page", inquiries);
model.addAttribute("commentCounts", commentCounts);
model.addAttribute("visibilityCeiling", inquiryFacade.getVisibilityCeiling());
return "apps/community/mainInquiryList";
}
@@ -75,13 +79,25 @@ public class InquiryController {
return "apps/community/mainInquiryDetail";
}
/**
* 2-1. 조회수 증가 (sessionStorage dedup 통과 시 JS 가 POST 호출)
*/
@PostMapping("/{id}/view")
public ResponseEntity<Void> increaseViewCount(@PathVariable String id) {
inquiryFacade.incrementViewCount(SecurityUtil.getPortalAuthenticatedUser(), id);
return ResponseEntity.ok().build();
}
/**
* 3. inquiry 등록 페이지
*/
@GetMapping("/new")
public String newInquiryForm(Model model) {
model.addAttribute("inquiry", new InquiryDTO());
InquiryDTO inquiry = new InquiryDTO();
inquiry.setVisibility(VisibilityScope.ORG.name()); // 기본 공개범위: 법인공개
model.addAttribute("inquiry", inquiry);
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
addVisibilityOptions(model);
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
@@ -93,14 +109,23 @@ public class InquiryController {
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
model.addAttribute("inquiry", inquiry);
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
addVisibilityOptions(model);
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
/** 공개범위 상한에 따른 폼 옵션 노출 플래그. PRIVATE 는 항상 허용. */
private void addVisibilityOptions(Model model) {
VisibilityScope ceiling = inquiryFacade.getVisibilityCeiling();
model.addAttribute("allowAll", ceiling.width() >= VisibilityScope.ALL.width());
model.addAttribute("allowOrg", ceiling.width() >= VisibilityScope.ORG.width());
}
/**
* 5. inquiry 등록 요청
*/
@PostMapping
public String createInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
@RequestParam(value = "image", required = false) MultipartFile image,
RedirectAttributes redirectAttributes,
Model model) throws IOException {
if (bindingResult.hasErrors()) {
@@ -108,7 +133,7 @@ public class InquiryController {
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
inquiryFacade.createInquiry(inquiryDTO);
inquiryFacade.createInquiry(inquiryDTO, image);
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
return REDIRECT_INQUIRY;
@@ -119,11 +144,12 @@ public class InquiryController {
*/
@PostMapping("/edit")
public String updateInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
@RequestParam(value = "image", required = false) MultipartFile image,
RedirectAttributes redirectAttributes) throws IOException {
if (bindingResult.hasErrors()) {
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO);
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO, image);
redirectAttributes.addFlashAttribute("success", "Q&A 수정이 완료되었습니다.");
return "redirect:/inquiry/detail?id=" + inquiryDTO.getId();
}
@@ -25,7 +25,7 @@ public class InquiryDTO {
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
private String inquiryDetail;
@Pattern(regexp = "^(PENDING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
@Pattern(regexp = "^(PENDING|REVIEWING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
private String inquiryStatus;
private String inquirerName;
@@ -51,4 +51,22 @@ public class InquiryDTO {
private String maskedInquirerName;
/** 목록 작성자 표기용 법인명(이름과 별도 줄 표기). */
private String inquirerOrgName;
/** 상세 작성자 표기용 "이름 (법인명)". */
private String inquirerDisplay;
/** 공개범위(ALL/ORG/PRIVATE). 미지정 시 서버에서 상한으로 clamp. */
private String visibility;
/** 공개범위 표기용 한글 라벨(전체공개/법인공개/비공개). */
private String visibilityLabel;
/** 조회수(표시용). */
private long viewCount;
/** 목록에서 비공개 게시물을 "비공개 게시물"로 흐리게 표기할지 여부(비-소유자). */
private boolean privatePlaceholder;
}
@@ -14,6 +14,9 @@ import org.springframework.stereotype.Component;
@Component
public interface InquiryMapper {
// visibility(공개범위)는 facade 에서 상한 clamp 후 수동 세팅, viewCount 는 서버가 관리
@Mapping(target = "visibility", ignore = true)
@Mapping(target = "viewCount", ignore = true)
Inquiry toEntity(InquiryDTO inquiry);
@Mapping(target = "inquirerName", source = "inquirer.userName")
@@ -3,23 +3,30 @@ package com.eactive.apim.portal.apps.community.qna.service;
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import java.io.IOException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.multipart.MultipartFile;
public interface InquiryFacade {
Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user);
/** 문의 공개범위 상한(폼 옵션 노출 제어용). */
VisibilityScope getVisibilityCeiling();
InquiryDTO getUserInquiry(PortalAuthenticatedUser user, String id);
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
void createInquiry(InquiryDTO inquiryDTO) throws IOException;
void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException;
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
void incrementViewCount(PortalAuthenticatedUser user, String id);
void deleteUserInquiry(PortalAuthenticatedUser user, String id);
}
@@ -7,26 +7,41 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.exception.InvalidFileException;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import org.apache.commons.io.FilenameUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class InquiryFacadeImpl implements InquiryFacade {
private static final Set<String> IMAGE_EXTENSIONS =
new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "gif"));
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final CommunityAdminNotifier inquiryAdminNotifier;
private final FileService fileService;
@Override
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
@@ -40,6 +55,8 @@ public class InquiryFacadeImpl implements InquiryFacade {
Page<Inquiry> inquiriesPage = inquiryService.getInquiries(spec, pageable);
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
return inquiriesPage.map(inquiry -> {
InquiryDTO dto = inquiryMapper.map(inquiry);
if (dto != null && dto.getInquirer() != null) {
@@ -48,8 +65,20 @@ public class InquiryFacadeImpl implements InquiryFacade {
inquiry.getInquirer().getId(),
user.getId()
));
PortalOrg org = inquiry.getInquirer().getPortalOrg();
if (org != null) {
dto.setInquirerOrgName(org.getOrgName());
}
dto.getInquirer().setUserName(null); // 원본 데이터 제거
}
// 비공개 게시물은 본인/같은 법인 관리자 외에는 "비공개 게시물"로만 노출
VisibilityScope effective = VisibilityScope.clampTo(inquiry.getVisibility(), ceiling);
dto.setVisibility(effective.name());
dto.setVisibilityLabel(effective.label());
if (effective == VisibilityScope.PRIVATE && !canReadPrivate(user, inquiry)) {
dto.setPrivatePlaceholder(true);
dto.setInquirySubject(null);
}
return dto;
});
}
@@ -81,14 +110,32 @@ public class InquiryFacadeImpl implements InquiryFacade {
@Override
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
return inquiryMapper.map(inquiry);
InquiryDTO dto = inquiryMapper.map(inquiry);
if (dto != null) {
VisibilityScope effective = inquiryService.effectiveVisibility(inquiry);
dto.setVisibility(effective.name());
dto.setVisibilityLabel(effective.label());
}
if (dto != null && inquiry.getInquirer() != null) {
// 상세 작성자 표기: "이름 (법인명)" — 본인은 owner-bypass 로 원문
String maskedName = StringMaskingUtil.maskName(
inquiry.getInquirer().getUserName(),
inquiry.getInquirer().getId(),
user.getId());
dto.setInquirerDisplay(withOrgName(maskedName, inquiry.getInquirer()));
}
return dto;
}
@Override
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
public void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
inquiry.setInquirer(current);
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
if (hasFile(image)) {
inquiry.setAttachFile(storeImage(null, image));
}
inquiryService.createInquiry(inquiry);
Map<String, Object> params = new HashMap<>();
@@ -99,17 +146,91 @@ public class InquiryFacadeImpl implements InquiryFacade {
}
@Override
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException {
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
inquiryDTO.setAttachFile(existingInquiry.getAttachFile());
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
inquiry.setInquirer(existingInquiry.getInquirer());
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
// 이미지 교체 시 기존 fileId 로 업데이트, 없으면 기존 첨부 유지
if (hasFile(image)) {
inquiry.setAttachFile(storeImage(existingInquiry.getAttachFile(), image));
} else {
inquiry.setAttachFile(existingInquiry.getAttachFile());
}
inquiryService.updateInquiry(id, inquiry);
}
@Override
public void incrementViewCount(PortalAuthenticatedUser user, String id) {
// 접근 가능한 게시물만 조회수 증가
inquiryService.getAccessibleInquiry(user, id);
inquiryService.incrementViewCount(id);
}
@Override
public VisibilityScope getVisibilityCeiling() {
return inquiryService.getVisibilityCeiling();
}
@Override
public void deleteUserInquiry(PortalAuthenticatedUser user, String id) {
inquiryService.deleteMyInquiry(user, id);
}
// =====================================================================
// helpers
// =====================================================================
/** 요청 공개범위를 기본 ORG 로 두고 property 상한으로 clamp. */
private VisibilityScope clampedVisibility(String requested) {
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
VisibilityScope scope = VisibilityScope.fromString(requested, VisibilityScope.ORG);
return VisibilityScope.clampTo(scope, ceiling);
}
/** 비공개 게시물을 읽을 수 있는 사용자(작성자 본인 또는 같은 법인 관리자). */
private boolean canReadPrivate(PortalAuthenticatedUser user, Inquiry inquiry) {
PortalUser inquirer = inquiry.getInquirer();
if (inquirer == null || user == null) {
return false;
}
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
return true;
}
return user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
&& isSameOrg(user, inquirer);
}
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
PortalOrg userOrg = user.getPortalOrg();
PortalOrg inquirerOrg = inquirer.getPortalOrg();
return userOrg != null && inquirerOrg != null
&& userOrg.getId() != null
&& userOrg.getId().equals(inquirerOrg.getId());
}
/** "이름 (법인명)" 조합. 법인명이 없으면 이름만. */
private String withOrgName(String name, PortalUser inquirer) {
PortalOrg org = inquirer.getPortalOrg();
if (org != null && org.getOrgName() != null && !org.getOrgName().isEmpty()) {
return name + " (" + org.getOrgName() + ")";
}
return name;
}
private boolean hasFile(MultipartFile file) {
return file != null && !file.isEmpty();
}
/** 이미지 확장자 검증 후 단일 파일 저장, fileId 반환. */
private String storeImage(String existingFileId, MultipartFile image) throws IOException {
String ext = FilenameUtils.getExtension(image.getOriginalFilename());
if (ext == null || !IMAGE_EXTENSIONS.contains(ext.toLowerCase())) {
throw new InvalidFileException("이미지 파일(jpg, jpeg, png, gif)만 첨부할 수 있습니다.");
}
FileInfo fileInfo = fileService.createOrUpdateSingleFile(
existingFileId, image, image.getOriginalFilename(), true);
return fileInfo.getFileId();
}
}
@@ -7,7 +7,9 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
@@ -19,10 +21,19 @@ import org.springframework.transaction.annotation.Transactional;
public class InquiryService {
public static final String PENDING = "PENDING";
private final InquiryRepository inquiryRepository;
public InquiryService(InquiryRepository inquiryRepository) {
/** 문의 공개범위 상한/기본값 property. */
private static final String PROP_GROUP = "Portal";
private static final String PROP_VISIBILITY_CEILING = "djb.inquiry.default-visibility";
private static final String PROP_VISIBILITY_DESC = "Q&A 문의 공개범위 상한/기본값 (ALL/ORG/PRIVATE)";
private final InquiryRepository inquiryRepository;
private final PortalPropertyService portalPropertyService;
public InquiryService(InquiryRepository inquiryRepository,
PortalPropertyService portalPropertyService) {
this.inquiryRepository = inquiryRepository;
this.portalPropertyService = portalPropertyService;
}
/**
@@ -75,6 +86,7 @@ public class InquiryService {
inquiry.setInquirySubject(updatedInquiry.getInquirySubject());
inquiry.setInquiryDetail(updatedInquiry.getInquiryDetail());
inquiry.setAttachFile(updatedInquiry.getAttachFile());
inquiry.setVisibility(updatedInquiry.getVisibility());
return inquiryRepository.save(inquiry);
}
@@ -112,12 +124,27 @@ public class InquiryService {
if (inquirer == null || user == null) {
return false;
}
// 작성자 본인은 공개범위와 무관하게 항상 접근 가능
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
return true;
}
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
return false;
VisibilityScope effective = effectiveVisibility(inquiry);
switch (effective) {
case ALL:
// 전체공개: 로그인 사용자면 접근 가능(@Secured 로 이미 인증됨)
return true;
case ORG:
return isSameOrg(user, inquirer);
case PRIVATE:
// 비공개: 본인 외에는 같은 법인 관리자만 열람
return isSameOrg(user, inquirer)
&& user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER;
default:
return false;
}
}
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
PortalOrg userOrg = user.getPortalOrg();
PortalOrg inquirerOrg = inquirer.getPortalOrg();
return userOrg != null && inquirerOrg != null
@@ -125,4 +152,30 @@ public class InquiryService {
&& userOrg.getId().equals(inquirerOrg.getId());
}
/**
* 게시물의 유효 공개범위 = 저장값을 property 상한으로 clamp 한 값.
* (property 가 항상 우선하므로 저장값이 옛 상한이라 넓더라도 재-clamp)
*/
@Transactional(readOnly = true)
public VisibilityScope effectiveVisibility(Inquiry inquiry) {
return VisibilityScope.clampTo(inquiry.getVisibility(), getVisibilityCeiling());
}
/** PTL_PROPERTY 에 설정된 공개범위 상한(없으면 ORG). */
@Transactional(readOnly = true)
public VisibilityScope getVisibilityCeiling() {
String value = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_VISIBILITY_CEILING, VisibilityScope.ORG.name(), PROP_VISIBILITY_DESC);
return VisibilityScope.fromString(value, VisibilityScope.ORG);
}
/**
* 조회수 1 증가. sessionStorage dedup 을 통과한 POST 요청에서만 호출된다.
*/
public void incrementViewCount(String id) {
Inquiry inquiry = getInquiry(id);
inquiry.increaseViewCount();
inquiryRepository.save(inquiry);
}
}
@@ -23,6 +23,10 @@ public class UserSessionService {
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
private static final String DEFAULT_KEEPALIVE_ENABLED = "false";
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
private static final long CLEANUP_MARGIN_MINUTES = 1;
@@ -133,6 +137,20 @@ public class UserSessionService {
}
}
/**
* DB(PortalProperty)에서 세션 유지(타임아웃 무시) 기능 활성화 여부 조회.
* ⚠️ prod 가드는 포함하지 않는다 — prod 차단은 호출부(GlobalControllerAdvice)에서 {@code !prod && isKeepAliveEnabled()}로 처리.
*/
public boolean isKeepAliveEnabled() {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP,
KEEPALIVE_PROPERTY_NAME,
DEFAULT_KEEPALIVE_ENABLED,
"세션 유지(타임아웃 무시) 기능 활성화 여부 (true/false, 비운영 전용)"
);
return value != null && Boolean.parseBoolean(value.trim());
}
/**
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
*/
@@ -4,11 +4,13 @@ import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.security.ClientGuardService;
@ControllerAdvice
public class GlobalControllerAdvice {
@@ -16,15 +18,18 @@ public class GlobalControllerAdvice {
@Autowired
private PageService pageService;
@Autowired
private PortalPropertyService portalPropertyService;
@Autowired
private PortalProperties portalProperties;
@Autowired
private UserSessionService userSessionService;
@Autowired
private ClientGuardService clientGuardService;
@Autowired
private Environment environment;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -37,17 +42,6 @@ public class GlobalControllerAdvice {
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")
public boolean showTestAuthNotice() {
return portalProperties.isTestAuthNoticeEnabled();
@@ -70,4 +64,33 @@ public class GlobalControllerAdvice {
public int sessionTimeoutMinutes() {
return userSessionService.getSessionTimeoutMinutes();
}
/**
* 세션 유지(타임아웃 무시) 기능 사용 가능 여부.
* 비운영(!prod) + DB property(Portal/session.keepalive.enabled=Y)일 때만 true.
* prod 환경에서는 property 값과 무관하게 항상 false → UI 체크박스 미렌더.
*/
@ModelAttribute("sessionKeepAliveAllowed")
public boolean sessionKeepAliveAllowed() {
boolean prod = environment.acceptsProfiles(Profiles.of("prod"));
return !prod && userSessionService.isKeepAliveEnabled();
}
/**
* 클라이언트 가드 - 우클릭(contextmenu) 차단 활성화 여부.
* PortalProperty(Portal/djb.client-guard.contextmenu.enabled)에서 조회.
*/
@ModelAttribute("clientGuardContextmenu")
public boolean clientGuardContextmenu() {
return clientGuardService.isContextmenuBlockEnabled();
}
/**
* 클라이언트 가드 - DevTools 감지 시 화면 삭제 활성화 여부.
* PortalProperty(Portal/djb.client-guard.devtools.enabled)에서 조회.
*/
@ModelAttribute("clientGuardDevtools")
public boolean clientGuardDevtools() {
return clientGuardService.isDevtoolsGuardEnabled();
}
}
@@ -0,0 +1,42 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 클라이언트측 하드닝(우클릭 차단 · DevTools 감지) 토글을 DB(PortalProperty)에서 조회한다.
*
* <p>두 기능은 서로 독립된 property 로 on/off 한다. group 은 기존 {@code Portal} 을 재사용하여
* {@link PortalPropertyService#getOrCreateProperty} 의 자동 생성이 동작하도록 한다.</p>
*
* <ul>
* <li>{@code djb.client-guard.contextmenu.enabled} - 우클릭(contextmenu) 차단</li>
* <li>{@code djb.client-guard.devtools.enabled} - DevTools 감지 시 화면 삭제</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class ClientGuardService {
private static final String GROUP = "Portal";
private static final String NAME_CONTEXTMENU = "djb.client-guard.contextmenu.enabled";
private static final String NAME_DEVTOOLS = "djb.client-guard.devtools.enabled";
private final PortalPropertyService portalPropertyService;
/** 우클릭(contextmenu) 차단 활성화 여부 */
public boolean isContextmenuBlockEnabled() {
return read(NAME_CONTEXTMENU, "우클릭(contextmenu) 차단");
}
/** DevTools 감지 시 화면 삭제 활성화 여부 */
public boolean isDevtoolsGuardEnabled() {
return read(NAME_DEVTOOLS, "DevTools 감지 시 화면 삭제");
}
private boolean read(String name, String desc) {
return Boolean.parseBoolean(
portalPropertyService.getOrCreateProperty(GROUP, name, "false", desc).trim());
}
}
@@ -122,6 +122,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
// 로그인 성공 시 세션 정보 로깅
logLoginSuccess(request, session, username);
@@ -0,0 +1,55 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.portalproperty.repository.PortalPropertyRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 기동 시 PTL_PROPERTY 무결성 점검.
*
* <p>PTL_PROPERTY 는 (PROPERTY_GROUP_NAME, PROPERTY_NAME) 조합이 유일해야 하지만, 운영 정책상
* DB에 유니크 제약을 걸지 않는다. 대신 부팅 완료 시점에 중복 키를 스캔하여 존재하면 ERROR 로그로
* 남긴다. (Spring Data 리포지토리는 인터페이스라 초기화 훅이 없어, 리포지토리를 사용하는 startup
* 리스너로 점검한다.)</p>
*
* <p>중복은 {@code getOrCreateProperty} 최초 조회가 동시 요청으로 경합할 때 각각 INSERT되며 생길 수
* 있다. 제약이 없으므로 중복 행은 자동 제거되지 않는다 — 로그를 보고 수동 정리해야 한다.</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PortalPropertyDuplicateChecker implements ApplicationListener<ApplicationReadyEvent> {
private final PortalPropertyRepository portalPropertyRepository;
@Override
@Transactional(readOnly = true)
public void onApplicationEvent(ApplicationReadyEvent event) {
List<Object[]> duplicates;
try {
duplicates = portalPropertyRepository.findDuplicatePropertyKeys();
} catch (Exception e) {
log.error("[PortalProperty 무결성] 중복 점검 쿼리 실패", e);
return;
}
if (duplicates == null || duplicates.isEmpty()) {
log.info("[PortalProperty 무결성] (PROPERTY_GROUP_NAME, PROPERTY_NAME) 중복 없음");
return;
}
// 1) (group, name) 은 유일해야 한다
// 2) 중복된 값 목록을 남긴다
log.error("[PortalProperty 무결성] (PROPERTY_GROUP_NAME, PROPERTY_NAME) 조합은 유일해야 합니다. "
+ "DB 유니크 제약이 없어 중복 {}건이 방치되어 있습니다. 수동 정리 필요:", duplicates.size());
for (Object[] row : duplicates) {
log.error(" - PROPERTY_GROUP_NAME='{}', PROPERTY_NAME='{}', count={}", row[0], row[1], row[2]);
}
}
}
@@ -0,0 +1,51 @@
package com.eactive.apim.portal.config;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.system.ApplicationPid;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
/**
* Prints a startup banner to stdout once boot is complete.
*
* <p>Uses {@link System#out} directly (not a logger) so the info is shown even when
* the logback console appender is disabled (CONSOLE_LOG_ENABLED=false).
*/
@Component
public class StartupInfoPrinter implements ApplicationListener<ApplicationReadyEvent> {
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
Environment env = event.getApplicationContext().getEnvironment();
String port = env.getProperty("server.port", "8080");
String contextPath = env.getProperty("server.servlet.context-path", "/");
if (!contextPath.startsWith("/")) {
contextPath = "/" + contextPath;
}
String url = "http://localhost:" + port + contextPath;
String[] active = env.getActiveProfiles();
String profile = active.length == 0
? String.join(", ", env.getDefaultProfiles())
: String.join(", ", active);
String instanceName = env.getProperty("inst.Name", System.getProperty("inst.Name", "unknown"));
String pid = new ApplicationPid().toString();
StringBuilder sb = new StringBuilder();
sb.append(System.lineSeparator());
sb.append("=====================================================").append(System.lineSeparator());
sb.append(" EAPIM Portal - startup complete").append(System.lineSeparator());
sb.append("=====================================================").append(System.lineSeparator());
sb.append(" URL : ").append(url).append(System.lineSeparator());
sb.append(" Spring profile : ").append(profile).append(System.lineSeparator());
sb.append(" Instance name : ").append(instanceName).append(System.lineSeparator());
sb.append(" PID : ").append(pid).append(System.lineSeparator());
sb.append("=====================================================").append(System.lineSeparator());
System.out.println(sb);
System.out.flush();
}
}
@@ -43,7 +43,8 @@ public class InquiryCommentController {
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
@Valid @RequestBody InquiryCommentCreateRequest request) {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(inquiryId, request.getContent(), current);
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(
inquiryId, request.getContent(), request.getVisibility(), current);
return ResponseEntity.ok(created);
}
@@ -11,4 +11,7 @@ public class InquiryCommentCreateRequest {
@NotBlank(message = "댓글 내용을 입력해주세요.")
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
private String content;
/** 공개범위(ALL=공개, PRIVATE=비공개). 미지정 시 공개. */
private String visibility;
}
@@ -26,4 +26,13 @@ public class InquiryCommentDTO {
private LocalDateTime createdDate;
private boolean deletable;
/** 공개범위(ALL/PRIVATE). */
private String visibility;
/** 비공개 댓글 여부 — 허용 독자에게는 "비공개" 태그로 표시. */
private boolean privateComment;
/** 비공개 댓글이지만 열람 권한이 없어 "비공개 댓글"로만 노출되는지 여부. */
private boolean privatePlaceholder;
}
@@ -11,7 +11,7 @@ public interface InquiryCommentFacade {
List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current);
InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current);
InquiryCommentDTO createUserComment(String inquiryId, String content, String visibility, PortalAuthenticatedUser current);
void deleteOwnComment(String commentId, PortalAuthenticatedUser current);
@@ -3,15 +3,19 @@ package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.apps.community.qna.service.InquiryService;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
@@ -37,6 +41,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
private static final String ADMIN_N = "N";
private static final String DEL_N = "N";
private static final String UNKNOWN_WRITER = "(알 수 없음)";
private final InquiryService inquiryService;
private final InquiryCommentService commentService;
@@ -51,21 +56,24 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
public List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current) {
inquiryService.getAccessibleInquiry(current, inquiryId);
List<InquiryComment> comments = commentService.findActiveByInquiryId(inquiryId);
Map<String, String> writerNames = resolveWriterNames(comments);
Map<String, Writer> writers = resolveWriters(comments);
return comments.stream()
.map(c -> toDto(c, current, writerNames))
.map(c -> toDto(c, current, writers))
.collect(Collectors.toList());
}
@Override
public InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current) {
public InquiryCommentDTO createUserComment(String inquiryId, String content, String visibility, PortalAuthenticatedUser current) {
Inquiry inquiry = inquiryService.getAccessibleInquiry(current, inquiryId);
permissionChecker.assertWritable(inquiry);
InquiryComment saved = commentService.create(inquiry, content, ADMIN_N);
// 댓글은 공개(ALL)/비공개(PRIVATE) 2단계만 지원
VisibilityScope scope = VisibilityScope.PRIVATE == VisibilityScope.fromString(visibility, VisibilityScope.ALL)
? VisibilityScope.PRIVATE : VisibilityScope.ALL;
InquiryComment saved = commentService.create(inquiry, content, ADMIN_N, scope);
publishAdminNotification(inquiry, saved, current);
Map<String, String> writerNames = new HashMap<>();
writerNames.put(current.getId(), current.getUserName());
return toDto(saved, current, writerNames);
Map<String, Writer> writers = new HashMap<>();
writers.put(current.getId(), currentWriter(current));
return toDto(saved, current, writers);
}
@Override
@@ -102,7 +110,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
}
private Map<String, String> resolveWriterNames(List<InquiryComment> comments) {
private Map<String, Writer> resolveWriters(List<InquiryComment> comments) {
Set<String> ids = new HashSet<>();
for (InquiryComment c : comments) {
String createdBy = c.getCreatedBy();
@@ -110,11 +118,11 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
ids.add(createdBy);
}
}
Map<String, String> map = new HashMap<>();
Map<String, Writer> map = new HashMap<>();
for (String id : ids) {
String name = lookupWriterName(id);
if (name != null && !name.isEmpty()) {
map.put(id, name);
Writer writer = lookupWriter(id);
if (writer != null && writer.name != null && !writer.name.isEmpty()) {
map.put(id, writer);
} else {
log.warn("댓글 작성자명 조회 실패 — createdBy={}, isUuid={}", id, isUuid(id));
}
@@ -122,23 +130,35 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
return map;
}
private String lookupWriterName(String createdBy) {
private Writer lookupWriter(String createdBy) {
if (isUuid(createdBy)) {
String name = portalUserRepository.findById(createdBy)
.map(PortalUser::getUserName).orElse(null);
if (name != null && !name.isEmpty()) {
return name;
PortalUser portalUser = portalUserRepository.findById(createdBy).orElse(null);
if (portalUser != null && portalUser.getUserName() != null && !portalUser.getUserName().isEmpty()) {
return toWriter(portalUser);
}
return userInfoRepository.findById(createdBy)
.map(UserInfo::getUsername).orElse(null);
UserInfo userInfo = userInfoRepository.findById(createdBy).orElse(null);
return userInfo != null ? new Writer(userInfo.getUsername(), null, null) : null;
}
String name = userInfoRepository.findById(createdBy)
.map(UserInfo::getUsername).orElse(null);
if (name != null && !name.isEmpty()) {
return name;
UserInfo userInfo = userInfoRepository.findById(createdBy).orElse(null);
if (userInfo != null && userInfo.getUsername() != null && !userInfo.getUsername().isEmpty()) {
return new Writer(userInfo.getUsername(), null, null);
}
return portalUserRepository.findById(createdBy)
.map(PortalUser::getUserName).orElse(null);
PortalUser portalUser = portalUserRepository.findById(createdBy).orElse(null);
return portalUser != null ? toWriter(portalUser) : null;
}
private Writer toWriter(PortalUser portalUser) {
PortalOrg org = portalUser.getPortalOrg();
return new Writer(portalUser.getUserName(),
org != null ? org.getId() : null,
org != null ? org.getOrgName() : null);
}
private Writer currentWriter(PortalAuthenticatedUser current) {
PortalOrg org = current.getPortalOrg();
return new Writer(current.getUserName(),
org != null ? org.getId() : null,
org != null ? org.getOrgName() : null);
}
private boolean isUuid(String value) {
@@ -154,15 +174,50 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
}
private InquiryCommentDTO toDto(InquiryComment entity, PortalAuthenticatedUser current,
Map<String, String> writerNames) {
Map<String, Writer> writers) {
String writerId = entity.getCreatedBy();
boolean deletable = writerId != null
&& writerId.equals(current.getId())
boolean isAdmin = "Y".equals(entity.getAdminYn());
boolean isPrivate = entity.isPrivate();
boolean owner = writerId != null && writerId.equals(current.getId());
Writer writer = writerId != null ? writers.get(writerId) : null;
// 비공개 댓글 열람 권한: 관리자 댓글은 대상 아님, 작성자 본인 또는 같은 법인 관리자만
boolean canSee = owner || canSeePrivate(current, writer);
String scopeName = entity.getVisibility() != null ? entity.getVisibility().name() : VisibilityScope.ALL.name();
if (isPrivate && !canSee) {
// 열람 권한 없는 비공개 댓글 → "비공개 댓글"로만 노출, 작성자 신원 숨김
return InquiryCommentDTO.builder()
.id(entity.getId())
.content("비공개 댓글")
.writerId(null)
.writerName("비공개")
.adminYn(entity.getAdminYn())
.createdDate(entity.getCreatedDate())
.deletable(false)
.visibility(scopeName)
.privateComment(true)
.privatePlaceholder(true)
.build();
}
boolean deletable = owner
&& entity.getInquiry() != null
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
String writerName = writerId != null ? writerNames.get(writerId) : null;
if (writerName == null || writerName.isEmpty()) {
writerName = "(알 수 없음)";
String writerName;
if (isAdmin) {
// 관리자 댓글은 실명 대신 "관리자"로만 표기 (신원 비노출)
writerName = "관리자";
} else {
String base = writer != null ? writer.name : null;
String masked = StringMaskingUtil.maskName(base, writerId, current.getId());
if (masked == null || masked.isEmpty()) {
masked = UNKNOWN_WRITER;
}
writerName = (writer != null && writer.orgName != null && !writer.orgName.isEmpty())
? masked + " (" + writer.orgName + ")"
: masked;
}
return InquiryCommentDTO.builder()
.id(entity.getId())
@@ -172,6 +227,34 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
.adminYn(entity.getAdminYn())
.createdDate(entity.getCreatedDate())
.deletable(deletable)
.visibility(scopeName)
.privateComment(isPrivate)
.privatePlaceholder(false)
.build();
}
/** 비공개 댓글을 볼 수 있는 같은 법인 관리자 여부. */
private boolean canSeePrivate(PortalAuthenticatedUser current, Writer writer) {
if (writer == null || writer.orgId == null) {
return false;
}
if (current.getRoleCode() != PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
return false;
}
PortalOrg org = current.getPortalOrg();
return org != null && org.getId() != null && org.getId().equals(writer.orgId);
}
/** 댓글 작성자 정보(이름/법인). */
private static final class Writer {
private final String name;
private final String orgId;
private final String orgName;
private Writer(String name, String orgId, String orgName) {
this.name = name;
this.orgId = orgId;
this.orgName = orgName;
}
}
}
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -25,12 +26,13 @@ public class InquiryCommentService {
return repository.findByInquiry_IdAndDelYnOrderByCreatedDateAsc(inquiryId, DEL_N);
}
public InquiryComment create(Inquiry inquiry, String content, String adminYn) {
public InquiryComment create(Inquiry inquiry, String content, String adminYn, VisibilityScope visibility) {
InquiryComment comment = new InquiryComment();
comment.setInquiry(inquiry);
comment.setCommentDetail(content);
comment.setAdminYn(adminYn);
comment.setDelYn(DEL_N);
comment.setVisibility(visibility != null ? visibility : VisibilityScope.ALL);
return repository.save(comment);
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbInquiryStatus {
public static final String PENDING = "PENDING";
public static final String REVIEWING = "REVIEWING";
public static final String RESPONDED = "RESPONDED";
public static final String CLOSED = "CLOSED";
@@ -12,4 +13,37 @@ public final class DjbInquiryStatus {
public static boolean isClosed(String 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 및 기본값
}
}
@@ -0,0 +1,37 @@
package com.eactive.apim.portal.djb.testbed.advice;
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedAuthController;
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedSpecController;
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 테스트베드 두 컨트롤러 한정 예외 → JSON 변환.
* (qna 로컬 핸들러와 동일한 {@code {code, message}} 포맷.)
*/
@RestControllerAdvice(assignableTypes = {DjbTestbedAuthController.class, DjbTestbedSpecController.class})
public class DjbTestbedExceptionHandler {
@ExceptionHandler(DjbUnsupportedAuthTypeException.class)
public ResponseEntity<Map<String, Object>> handleUnsupportedAuthType(DjbUnsupportedAuthTypeException ex) {
Map<String, Object> body = new HashMap<>();
body.put("code", "UNSUPPORTED_AUTHTYPE");
body.put("message", ex.getMessage());
body.put("authtype", ex.getAuthtype());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
Map<String, Object> body = new HashMap<>();
body.put("code", "FORBIDDEN");
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
}
@@ -0,0 +1,157 @@
package com.eactive.apim.portal.djb.testbed.config;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 테스트베드 게이트웨이 설정을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.gateway.})
* 에서 읽는 얇은 접근자. 별도 property-injection 인프라 대신 기존
* {@link PortalPropertyService#getOrCreateProperty} 를 직접 사용한다(사용자 지시).
*
* <p>{@code getOrCreateProperty} 는 DB row 미존재 시 default 로 INSERT(그룹 존재 시)하지만
* <em>공백 값 보정은 하지 않으므로</em>, 여기 {@link #resolve} 에서 {@code isBlank → default} 폴백을 둔다.
*
* <p>비-Spring 컴포넌트인 {@code ApiTesterFilter}(@WebFilter)는
* {@code ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class)} 로 접근한다.
*/
@Component
@RequiredArgsConstructor
public class DjbTestbedGatewayProperty {
private final PortalPropertyService portalPropertyService;
private static final String GROUP = "Portal";
public static final String KEY_BASE_URL = "djb.gateway.base-url";
public static final String KEY_TOKEN_PATH = "djb.gateway.token-path";
public static final String KEY_API_KEY_HEADER = "djb.gateway.api-key-header";
public static final String KEY_OAUTH_HEADER = "djb.gateway.oauth-token-header";
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
public static final String DEFAULT_BASE_URL = "PortalMock";
public static final String DEFAULT_TIMEOUT_SEC = "10";
public static final String DEFAULT_USE_PROXY = "true";
public static final String DEFAULT_TOKEN_USE_PROXY = "true";
private static final int TIMEOUT_FALLBACK_MS = 10_000;
/** GW 모드 실제 토큰 엔드포인트 경로 (DJERP_4000 OAuth 발급가이드 v0.2). */
public static final String DEFAULT_TOKEN_PATH = "/dj/oauth/token";
public static final String DEFAULT_API_KEY_HEADER = "X-DJB-API-Key";
/** 게이트웨이 API 호출 시 액세스 토큰 헤더명 (가이드 v0.2: Authorization → X-AUTH-TOKEN). */
public static final String DEFAULT_OAUTH_HEADER = "X-AUTH-TOKEN";
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
public String baseUrl() {
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
}
public String tokenPath() {
return resolve(KEY_TOKEN_PATH, DEFAULT_TOKEN_PATH,
"OAuth 토큰 발급 경로 (GW 모드에서만 사용)");
}
public String apiKeyHeader() {
return resolve(KEY_API_KEY_HEADER, DEFAULT_API_KEY_HEADER,
"API Key 전달 헤더명");
}
public String oauthHeader() {
return resolve(KEY_OAUTH_HEADER, DEFAULT_OAUTH_HEADER,
"OAuth 액세스 토큰 전달 헤더명 (Bearer 토큰)");
}
/**
* 테스트베드 API 호출 시 서버 프록시({@code /api/call-api}) 사용 여부.
* {@code false} 이면 브라우저에서 대상 주소로 직접 호출(CORS 허용 필요). 기본 true.
*/
public boolean useProxy() {
return parseBool(resolve(KEY_USE_PROXY, DEFAULT_USE_PROXY,
"테스트베드 API 호출 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
}
/**
* 토큰 발급 시 서버 프록시({@code /api/call-api}) 사용 여부. API 호출 프록시와 별개로 설정.
* {@code false} 이면 브라우저에서 토큰 URL 로 직접 호출(CORS 허용 필요). 기본 true.
* (mock 모드는 포탈 자체 mock 토큰이라 프록시 유지가 자연스럽고, 실 GW 는 직접호출로 뺄 수 있음.)
*/
public boolean tokenUseProxy() {
return parseBool(resolve(KEY_TOKEN_USE_PROXY, DEFAULT_TOKEN_USE_PROXY,
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
}
/**
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
*/
private static boolean parseBool(String v, boolean def) {
if (v == null) {
return def;
}
String s = v.trim();
if (s.equalsIgnoreCase("true") || s.equalsIgnoreCase("Y")) {
return true;
}
if (s.equalsIgnoreCase("false") || s.equalsIgnoreCase("N")) {
return false;
}
return def;
}
/** 테스트베드 프록시 연결/응답 타임아웃(ms). 미설정/파싱 실패 시 10초. (단위 저장: 초) */
public int timeoutMillis() {
try {
int sec = Integer.parseInt(resolve(KEY_TIMEOUT_SEC, DEFAULT_TIMEOUT_SEC,
"테스트베드 프록시 연결/응답 타임아웃(초)"));
return sec > 0 ? sec * 1000 : TIMEOUT_FALLBACK_MS;
} catch (Exception e) {
return TIMEOUT_FALLBACK_MS;
}
}
public DjbGatewayMode resolveGatewayMode() {
return DEFAULT_BASE_URL.equalsIgnoreCase(baseUrl()) ? DjbGatewayMode.PORTAL_MOCK : DjbGatewayMode.GATEWAY;
}
/**
* OAuth 토큰 발급 URL.
* PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔),
* GATEWAY → {@code baseUrl + tokenPath}.
*/
public String resolveTokenUrl(String portalOrigin) {
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
}
return stripTrailingSlash(baseUrl()) + tokenPath();
}
/**
* GW 응답유형(spec responseType=gw)에서 사용할 API 호출 base URL.
* PortalMock → 요청 포탈 origin(mock 필터가 프록시), GATEWAY → {@code baseUrl}.
* (별도 {@code swagger.gw.address} 프로퍼티를 두지 않고 이 값을 단일 기준으로 사용한다.)
*/
public String resolveApiBaseUrl(String portalOrigin) {
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return stripTrailingSlash(portalOrigin);
}
return stripTrailingSlash(baseUrl());
}
private String resolve(String key, String defaultValue, String description) {
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
return StringUtils.hasText(value) ? value.trim() : defaultValue;
}
private static String stripTrailingSlash(String s) {
if (s == null) {
return "";
}
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
}
@@ -0,0 +1,51 @@
package com.eactive.apim.portal.djb.testbed.controller;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 테스트베드 인증 컨텍스트/시크릿 REST.
* 컨트롤러 진입은 인증 없이 허용하되(비대상자도 context 를 받아야 함),
* 실제 인증/역할/소유권 검증은 {@link DjbTestbedAuthService} 가 수행한다.
*/
@RestController
@RequestMapping("/djb/testbed/auth")
@RequiredArgsConstructor
public class DjbTestbedAuthController {
private final DjbTestbedAuthService authService;
@GetMapping("/context")
public ResponseEntity<DjbTestbedContextDto> context(HttpServletRequest request) {
return ResponseEntity.ok(authService.buildContext(resolveOrigin(request)));
}
@GetMapping("/credentials/{clientId}/secret")
public ResponseEntity<DjbCredentialSecretDto> credentialSecret(@PathVariable String clientId,
@RequestParam String apiId) {
DjbCredentialSecretDto secret = authService.getCredentialSecret(clientId, apiId);
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.header(HttpHeaders.PRAGMA, "no-cache")
.body(secret);
}
/** scheme://host[:port] (기본 포트는 생략). PortalMock 토큰 URL 구성에 사용. */
private String resolveOrigin(HttpServletRequest request) {
String scheme = request.getScheme();
String host = request.getServerName();
int port = request.getServerPort();
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
return scheme + "://" + host + (defaultPort ? "" : ":" + port);
}
}
@@ -0,0 +1,75 @@
package com.eactive.apim.portal.djb.testbed.controller;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.eactive.apim.portal.djb.testbed.service.DjbSwaggerSpecEnricher;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.FileCopyUtils;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에
* 따른 실주소로 치환해 반환한다.
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
*/
@RestController
@RequestMapping("/djb/testbed/apis")
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedSpecController {
private final ApiSpecInfoService apiSpecInfoService;
private final DjbTestbedAuthService authService;
private final DjbSwaggerSpecEnricher enricher;
private final DjbTestbedSpecServerRewriter serverRewriter;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null;
}
DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
return serverRewriter.rewriteServer(enriched, spec.get(), request);
}
}
@@ -0,0 +1,21 @@
package com.eactive.apim.portal.djb.testbed.dto;
import lombok.Builder;
import lombok.Data;
/**
* 테스트베드 앱 셀렉트에 노출할 앱(PTL_CREDENTIAL) 1건. 시크릿은 포함하지 않는다.
*/
@Data
@Builder
public class DjbCredentialOptionDto {
/** PTL_CREDENTIAL.clientid */
private String clientId;
/** 사용자에게 보여줄 앱명(PTL_CREDENTIAL.clientname) */
private String clientName;
/** "0": 차단, "1": 정상 */
private String appStatus;
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.testbed.dto;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import lombok.Builder;
import lombok.Data;
/**
* 선택한 앱의 시크릿 단건 응답(캐시 금지 · 로그 마스킹 대상).
* OAUTH 시 {@code clientSecret} 은 client_secret, API_KEY 시 API Key 값이다.
*/
@Data
@Builder
public class DjbCredentialSecretDto {
private String clientId;
private String clientSecret;
private DjbAuthType authType;
}
@@ -0,0 +1,36 @@
package com.eactive.apim.portal.djb.testbed.dto;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.util.List;
import lombok.Builder;
import lombok.Data;
/**
* {@code GET /djb/testbed/auth/context} 응답. 시크릿은 포함하지 않는다.
*/
@Data
@Builder
public class DjbTestbedContextDto {
/** 인증 정보 제공 대상자 여부 */
private boolean eligible;
/** 비대상 사유 코드: ANONYMOUS / INDIVIDUAL_USER / NO_CREDENTIAL (대상자면 null) */
private String reason;
/** 사용자가 선택할 수 있는 앱 목록 */
private List<DjbCredentialOptionDto> credentials;
/** 게이트웨이 정보(시크릿 제외) */
private GatewayInfo gateway;
@Data
@Builder
public static class GatewayInfo {
private DjbGatewayMode mode;
private String baseUrl;
private String tokenUrl;
private String apiKeyHeader;
private String oauthTokenHeader;
}
}
@@ -0,0 +1,42 @@
package com.eactive.apim.portal.djb.testbed.enums;
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
/**
* 테스트베드가 지원하는 인증 체계. {@code TSEAIHE01.AUTHTYPE}(소문자 저장) 값을 매핑한다.
* <ul>
* <li>{@code oauth}/{@code oauth2}/{@code ca} → {@link #OAUTH}</li>
* <li>{@code api_key}/{@code apikey} → {@link #API_KEY}</li>
* <li>{@code none}/{@code no}/{@code noauth} → {@link #NONE}(인증 없이 호출 — 앱 선택 불필요)</li>
* <li>{@code null}(EAIMessage 미존재) 및 그 외 → {@link DjbUnsupportedAuthTypeException}</li>
* </ul>
* ({@code ca} 는 게이트웨이 레거시 매퍼 {@code ObpApiService.mapAuthType} 이 OAUTH2 로 취급하는 값.)
*/
public enum DjbAuthType {
OAUTH,
API_KEY,
NONE;
public static DjbAuthType fromAuthtype(String authtype) {
// null(EAIMessage 미존재)은 데이터 문제 → 종전대로 예외. "none" 은 인증 없는 API.
if (authtype == null) {
throw new DjbUnsupportedAuthTypeException("null");
}
String normalized = authtype.trim().toLowerCase();
switch (normalized) {
case "oauth":
case "oauth2":
case "ca":
return OAUTH;
case "api_key":
case "apikey":
return API_KEY;
case "none":
case "no":
case "noauth":
return NONE;
default:
throw new DjbUnsupportedAuthTypeException(authtype);
}
}
}
@@ -0,0 +1,14 @@
package com.eactive.apim.portal.djb.testbed.enums;
/**
* 테스트베드 호출이 향하는 대상 모드.
* <ul>
* <li>{@code PORTAL_MOCK} — {@code djb.gateway.base-url} 이 문자열 "PortalMock" 인 경우.
* {@code ApiTesterFilter} 가 mock 토큰/샘플 응답을 반환한다.</li>
* <li>{@code GATEWAY} — 그 외(실제 게이트웨이 URL). 토큰 발급 요청을 실 게이트웨이로 forward.</li>
* </ul>
*/
public enum DjbGatewayMode {
PORTAL_MOCK,
GATEWAY
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.testbed.exception;
/**
* TSEAIHE01.AUTHTYPE 값이 테스트베드가 지원하는 인증 체계(oauth/api_key)로
* 매핑되지 않을 때 발생. {@link com.eactive.apim.portal.djb.testbed.advice.DjbTestbedExceptionHandler}
* 에서 400 응답으로 변환된다.
*/
public class DjbUnsupportedAuthTypeException extends RuntimeException {
private final String authtype;
public DjbUnsupportedAuthTypeException(String authtype) {
super("지원하지 않는 인증 체계: " + authtype);
this.authtype = authtype;
}
public String getAuthtype() {
return authtype;
}
}
@@ -0,0 +1,106 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* testbed spec(JSON)에 인증 체계를 주입한다.
*
* <p>현재 데이터는 <b>OpenAPI 3.0</b>(auto-gen {@code "openapi":"3.0.0"})이므로
* {@code components.securitySchemes} + 글로벌 {@code security} 에 주입한다.
* (원본이 Swagger 2.0 이면 {@code securityDefinitions} 로 폴백.)
*
* <p>게이트웨이가 OAuth 액세스 토큰을 표준 {@code Authorization} 이 아니라 커스텀 헤더
* {@code X-AUTH-TOKEN: Bearer ...} 로 받으므로(DJERP_4000 v0.2), OAuth 도 API_KEY 와 동일하게
* <b>apiKey-in-header</b> 스킴으로 모델링한다(헤더명만 상이). 토큰 발급은 템플릿 JS가
* {@code /api/call-api} 경유로 수행해 값을 {@code "Bearer <token>"} 으로 authorize 한다.
*
* <p>원본의 {@code paths}, {@code x-*} 확장, 기타 {@code components} 는 트리 조작으로 보존한다
* (문자열 치환 금지).
*/
@Service
@RequiredArgsConstructor
public class DjbSwaggerSpecEnricher {
private final ObjectMapper objectMapper;
private final DjbTestbedGatewayProperty gatewayProperty;
public static final String OAUTH_SCHEME = "djbOAuth";
public static final String API_KEY_SCHEME = "djbApiKey";
public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
// 예상 밖 형태면 원본 유지(멱등)
return specJson;
}
ObjectNode root = (ObjectNode) parsed;
// NONE: 인증 없이 호출 — 글로벌 security / securityScheme 흔적을 제거해
// Swagger UI 의 'Authorize' 버튼이 뜨지 않게 한다. (저장 spec 에 빈 securitySchemes 가
// 남아 있으면 UI 가 빈 인증 모달 버튼을 렌더하므로 정리한다.)
if (authType == DjbAuthType.NONE) {
return stripSecurity(root);
}
String schemeName = (authType == DjbAuthType.OAUTH) ? OAUTH_SCHEME : API_KEY_SCHEME;
ObjectNode scheme = buildApiKeyScheme(authType);
boolean swagger2 = root.has("swagger") && !root.has("openapi");
if (swagger2) {
getOrCreateObject(root, "securityDefinitions").set(schemeName, scheme);
} else {
ObjectNode components = getOrCreateObject(root, "components");
getOrCreateObject(components, "securitySchemes").set(schemeName, scheme);
}
// 글로벌 security 요구사항 (apiKey 스킴은 빈 스코프 배열)
ArrayNode security = objectMapper.createArrayNode();
ObjectNode requirement = objectMapper.createObjectNode();
requirement.set(schemeName, objectMapper.createArrayNode());
security.add(requirement);
root.set("security", security);
return objectMapper.writeValueAsString(root);
}
/** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */
private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
String headerName = (authType == DjbAuthType.OAUTH)
? gatewayProperty.oauthHeader()
: gatewayProperty.apiKeyHeader();
ObjectNode scheme = objectMapper.createObjectNode();
scheme.put("type", "apiKey");
scheme.put("in", "header");
scheme.put("name", headerName);
return scheme;
}
/** NONE 용: 글로벌 {@code security} / {@code components.securitySchemes} / {@code securityDefinitions} 제거. */
private String stripSecurity(ObjectNode root) throws JsonProcessingException {
root.remove("security");
root.remove("securityDefinitions"); // swagger 2.0
JsonNode components = root.get("components");
if (components != null && components.isObject()) {
((ObjectNode) components).remove("securitySchemes");
}
return objectMapper.writeValueAsString(root);
}
private ObjectNode getOrCreateObject(ObjectNode parent, String field) {
JsonNode node = parent.get(field);
if (node != null && node.isObject()) {
return (ObjectNode) node;
}
ObjectNode created = objectMapper.createObjectNode();
parent.set(field, created);
return created;
}
}
@@ -0,0 +1,133 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.gateway.data.eaimessage.EAIMessageRepository;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialOptionDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto.GatewayInfo;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.eai.data.entity.onl.message.EAIMessageEntity;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
/**
* 테스트베드 인증 컨텍스트 구성 · 앱 시크릿 조회 · authtype 매핑.
* 인증/역할/소유권 검증은 이 서비스에서 수행한다({@code SecurityFilterChain} 은 URL 인가 블록이 없음).
*/
@Service
@RequiredArgsConstructor
public class DjbTestbedAuthService {
private final CredentialRepository credentialRepository;
private final EAIMessageRepository eaiMessageRepository;
private final DjbTestbedGatewayProperty gatewayProperty;
private static final String APP_STATUS_ACTIVE = "1";
/**
* 비대상 사유: {@code ANONYMOUS}(비로그인) / {@code INDIVIDUAL_USER}(ROLE_USER) /
* {@code NO_CREDENTIAL}(appstatus='1' 앱 0건). 하나라도 해당하면 {@code eligible=false}.
*/
public DjbTestbedContextDto buildContext(String portalOrigin) {
GatewayInfo gateway = buildGatewayInfo(portalOrigin);
if (!SecurityUtil.isAuthenticated()) {
return notEligible("ANONYMOUS", gateway);
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user.getRoleCode() == RoleCode.ROLE_USER) {
return notEligible("INDIVIDUAL_USER", gateway);
}
String orgId = SecurityUtil.getUserOrg().getId();
List<DjbCredentialOptionDto> options = credentialRepository.findAllByOrgid(orgId).stream()
.filter(c -> APP_STATUS_ACTIVE.equals(c.getAppstatus()))
.map(this::toOption)
.collect(Collectors.toList());
if (options.isEmpty()) {
return notEligible("NO_CREDENTIAL", gateway);
}
return DjbTestbedContextDto.builder()
.eligible(true)
.reason(null)
.credentials(options)
.gateway(gateway)
.build();
}
/**
* 선택 앱의 시크릿 단건. 로그인 + 비-ROLE_USER + 본인 소유(orgId 일치) 검증 후 반환.
* 소유 불일치/비대상은 {@link AccessDeniedException}(→ 403).
*/
public DjbCredentialSecretDto getCredentialSecret(String clientId, String apiId) {
if (!SecurityUtil.isAuthenticated()) {
throw new AccessDeniedException("로그인이 필요합니다.");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user.getRoleCode() == RoleCode.ROLE_USER) {
throw new AccessDeniedException("개인사용자는 이용할 수 없습니다.");
}
String orgId = SecurityUtil.getUserOrg().getId();
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new AccessDeniedException("본인 소유의 앱이 아닙니다."));
DjbAuthType authType = resolveAuthType(apiId);
return DjbCredentialSecretDto.builder()
.clientId(credential.getClientid())
.clientSecret(credential.getClientsecret())
.authType(authType)
.build();
}
/**
* apiId(=TSEAIHE01.eaisvcname, admin 입력 컨벤션)로 authtype 조회 후 매핑.
* 미매핑/미존재는 {@code DjbUnsupportedAuthTypeException}(→ 400).
*/
public DjbAuthType resolveAuthType(String apiId) {
String authtype = eaiMessageRepository.findById(apiId)
.map(EAIMessageEntity::getAuthtype)
.orElse(null);
return DjbAuthType.fromAuthtype(authtype);
}
private DjbCredentialOptionDto toOption(Credential c) {
return DjbCredentialOptionDto.builder()
.clientId(c.getClientid())
.clientName(c.getClientname())
.appStatus(c.getAppstatus())
.build();
}
private GatewayInfo buildGatewayInfo(String portalOrigin) {
return GatewayInfo.builder()
.mode(gatewayProperty.resolveGatewayMode())
.baseUrl(gatewayProperty.baseUrl())
.tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin))
.apiKeyHeader(gatewayProperty.apiKeyHeader())
.oauthTokenHeader(gatewayProperty.oauthHeader())
.build();
}
private DjbTestbedContextDto notEligible(String reason, GatewayInfo gateway) {
return DjbTestbedContextDto.builder()
.eligible(false)
.reason(reason)
.credentials(Collections.emptyList())
.gateway(gateway)
.build();
}
}
@@ -0,0 +1,110 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
/**
* testbed spec(JSON)에 baking 된 서버 sentinel({@value #SERVER_SENTINEL})을
* API SPEC 설정(responseType)에 따른 실주소로 치환한다.
*
* <ul>
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
* <li>mock : {@code ApiSpecInfo.mockUrl}</li>
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
* </ul>
*
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
* admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며,
* 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다.
*
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedSpecServerRewriter {
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
public static final String SERVER_SENTINEL = "http://swagger-server-url";
private final DjbTestbedGatewayProperty gatewayProperty;
private final ObjectMapper objectMapper;
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
}
String base = stripTrailingSlash(resolveBase(spec, request));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
}
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
public String toYaml(String specJson) {
try {
Object tree = objectMapper.readValue(specJson, Object.class);
DumperOptions opts = new DumperOptions();
opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
opts.setPrettyFlow(true);
return new Yaml(opts).dump(tree);
} catch (Exception e) {
log.error("spec YAML 변환 실패, JSON 원본 반환", e);
return specJson;
}
}
private String resolveBase(ApiSpecInfo spec, HttpServletRequest request) {
String rt = (spec == null || !StringUtils.hasText(spec.getResponseType()))
? "sample" : spec.getResponseType().trim();
if ("gw".equalsIgnoreCase(rt)) {
return gatewayProperty.resolveApiBaseUrl(originOf(request));
}
if ("mock".equalsIgnoreCase(rt)) {
String mock = (spec == null) ? null : spec.getMockUrl();
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
}
// sample(기본): 포탈 origin
return originOf(request);
}
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
public static String originOf(HttpServletRequest request) {
if (request == null) {
return "";
}
String scheme = firstNonBlank(request.getHeader("X-Forwarded-Proto"), request.getScheme());
String host = firstNonBlank(request.getHeader("X-Forwarded-Host"), request.getHeader("Host"));
if (StringUtils.hasText(host)) {
return scheme + "://" + host.trim();
}
int port = request.getServerPort();
String hostPort = request.getServerName() + ((port == 80 || port == 443) ? "" : ":" + port);
return scheme + "://" + hostPort;
}
private static String firstNonBlank(String a, String b) {
return StringUtils.hasText(a) ? a.trim() : b;
}
private static String stripTrailingSlash(String s) {
if (s == null) {
return null;
}
String t = s.trim();
while (t.endsWith("/")) {
t = t.substring(0, t.length() - 1);
}
return t;
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ spring:
jpa:
properties:
hibernate:
show_sql: true
format_sql: true
show_sql: false
format_sql: false
use_sql_comments: true
devtools:
restart:
+9 -5
View File
@@ -2,9 +2,13 @@ server:
servlet:
context-path: /
session:
timeout: 10m
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
# timeout: 10m
cookie:
name: PORTAL_JSESSIONID
name: JSESSIONID_PORTAL
encoding:
charset: UTF-8
port: '39130'
@@ -314,6 +318,9 @@ page:
faq_list:
name: FAQ
path: "/faq_list"
faq_detail:
name: FAQ
path: "/faq_view"
inquiry:
name: "Q&A"
path: "/inquiry"
@@ -378,9 +385,6 @@ page:
myapikey_modify_step3:
name: "앱 수정 요청 완료"
path: "/myapikey/modify/step3"
commission:
name: "과금 관리"
path: "/commission/manage"
api_statistics:
name: "이용 통계"
path: "/statistics/api"
+261 -411
View File
@@ -663,75 +663,6 @@ hr {
--shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
}
.design-survey-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
z-index: 400;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.design-survey-bar .survey-container {
display: flex;
align-items: center;
gap: 16px;
}
.design-survey-bar .survey-label {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
.design-survey-bar .survey-buttons {
display: flex;
gap: 8px;
}
.design-survey-bar .survey-btn {
padding: 6px 16px;
border: 2px solid rgba(255, 255, 255, 0.5);
border-radius: 20px;
background: transparent;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.design-survey-bar .survey-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
}
.design-survey-bar .survey-btn.active {
background: #ffffff;
color: #667eea;
border-color: #ffffff;
}
@media (max-width: 768px) {
.design-survey-bar {
height: 40px;
}
.design-survey-bar .survey-label {
display: none;
}
.design-survey-bar .survey-btn {
padding: 4px 12px;
font-size: 12px;
}
}
body.design-survey-active .global-header {
margin-top: 48px;
}
@media (max-width: 768px) {
body.design-survey-active .global-header {
margin-top: 40px;
}
}
.blind {
position: absolute;
width: 1px;
@@ -5333,14 +5264,44 @@ select.form-control {
font-weight: 700;
}
.session-timer-mobile {
margin-right: 8px;
font-size: 12px;
.session-float {
position: fixed;
top: 16px;
right: 20px;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.session-timer-mobile .session-timer-text {
min-width: 34px;
.session-float .session-keepalive {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding-left: 8px;
border-left: 1px solid #e0e0e0;
font-size: 12px;
color: #64748B;
cursor: pointer;
white-space: nowrap;
}
.session-float .session-keepalive input {
margin: 0;
cursor: pointer;
}
@media (max-width: 768px) {
.session-float {
top: 12px;
right: 72px;
padding: 4px 10px;
}
}
.data-table {
width: 100%;
background: #FFFFFF;
@@ -6973,6 +6934,37 @@ button.djb-comment-submit:disabled {
font-size: 13px;
}
.djb-comment-private-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: auto;
color: #6B7280;
font-size: 13px;
cursor: pointer;
}
.djb-comment-private-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 10px;
background-color: #FEF3C7;
color: #92400E;
font-size: 11px;
vertical-align: middle;
}
.djb-comment-item--private {
background-color: #FFFBEB;
}
.djb-comment-item--private-hidden .djb-comment-body,
.djb-comment-item--private-hidden .djb-comment-writer {
color: #9CA3AF;
font-style: italic;
}
.hero-carousel-section {
position: relative;
width: 100%;
@@ -9451,6 +9443,117 @@ button.djb-comment-submit:disabled {
display: flex;
}
.testbed-app-panel {
margin: 8px 0 24px;
padding: 24px;
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.testbed-app-panel .testbed-app-panel__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
margin-bottom: 16px;
}
.testbed-app-panel .testbed-app-panel__title {
position: relative;
padding-left: 16px;
font-size: 16px;
font-weight: 700;
color: #1A1A2E;
letter-spacing: -0.01em;
}
.testbed-app-panel .testbed-app-panel__title::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 15px;
background: #0049b4;
border-radius: 2px;
}
.testbed-app-panel .testbed-app-panel__desc {
font-size: 14px;
color: #64748B;
}
.testbed-app-panel .testbed-app-field {
position: relative;
max-width: 380px;
}
.testbed-app-panel .testbed-app-field::after {
content: "";
position: absolute;
right: 16px;
top: 50%;
width: 9px;
height: 9px;
margin-top: -6px;
border-right: 2px solid #64748B;
border-bottom: 2px solid #64748B;
transform: rotate(45deg);
pointer-events: none;
}
.testbed-app-panel #apps {
width: 100%;
height: 46px;
padding: 0 42px 0 16px;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
color: #1A1A2E;
background: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 8px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: all 0.15s ease;
}
.testbed-app-panel #apps:hover:not(:disabled) {
border-color: #94A3B8;
background: #FFFFFF;
}
.testbed-app-panel #apps:focus {
outline: none;
background: #FFFFFF;
border-color: #0049b4;
box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.14);
}
.testbed-app-panel #apps:disabled {
color: #94A3B8;
background: #F8FAFC;
cursor: not-allowed;
}
.testbed-app-panel .testbed-app-notice {
margin: 16px 0 0;
padding: 11px 16px;
font-size: 14px;
line-height: 1.6;
color: #64748B;
background: #EFF6FF;
border: 1px solid rgba(0, 73, 180, 0.18);
border-left: 3px solid #0049b4;
border-radius: 6px;
}
.testbed-app-panel .testbed-app-notice::before {
content: "ⓘ ";
color: #0049b4;
font-weight: 700;
}
@media (max-width: 768px) {
.testbed-app-panel {
padding: 16px;
}
.testbed-app-panel .testbed-app-field {
max-width: 100%;
}
}
.api-overview-card {
background: transparent;
border-radius: 0;
@@ -15604,6 +15707,87 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.list-table-row--private {
opacity: 0.55;
cursor: default;
}
.list-table-row--private .inquiry-private-label {
color: #94A3B8;
font-style: italic;
}
.notice-title-link--disabled {
pointer-events: none;
cursor: default;
}
.inquiry-detail-views {
color: #94A3B8;
font-size: 13px;
}
.inquiry-detail-attach {
margin-top: 16px;
}
.inquiry-detail-attach .inquiry-attach-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid #CBD5E1;
border-radius: 6px;
color: #1E40AF;
font-size: 13px;
text-decoration: none;
}
.inquiry-detail-attach .inquiry-attach-link:hover {
background-color: #F1F5F9;
}
.row-cell--writer {
flex-direction: column;
justify-content: center;
line-height: 1.3;
}
.row-cell--writer .inquiry-writer-org {
color: #94A3B8;
font-size: 12px;
}
.inquiry-visibility-notice {
margin-left: 12px;
font-size: 13px;
font-weight: normal;
color: #64748B;
}
.list-table-body .inquiry-status-badge {
height: 24px;
padding: 0 10px;
font-size: 12px;
}
.inquiry-lock-icon {
vertical-align: -2px;
margin-right: 3px;
color: #64748B;
}
.inquiry-detail-visibility {
display: inline-flex;
align-items: center;
gap: 2px;
color: #64748B;
font-size: 13px;
}
.inquiry-detail-visibility--private {
color: #c0392b;
font-weight: 600;
}
.inquiry-detail-visibility--private .inquiry-lock-icon {
color: #c0392b;
}
.org-register-page {
min-height: 100vh;
padding: 0;
@@ -16966,6 +17150,9 @@ input[type=checkbox]:checked + .custom-checkbox {
.inquiry-status-badge--pending {
background-color: #8c959f;
}
.inquiry-status-badge--reviewing {
background-color: #e08e0b;
}
.inquiry-status-badge--closed {
background-color: #4a5560;
}
@@ -17467,343 +17654,6 @@ input[type=checkbox]:checked + .custom-checkbox {
transform: translateY(4px);
}
.commission-container {
max-width: 1400px;
margin: 0 auto;
}
.wrap {
max-width: 1400px;
margin: 0 auto;
background: #FFFFFF;
padding: 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
.searchInfo {
display: inline-block;
margin-right: 8px;
margin-bottom: 16px;
}
.searchBox {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.searchBox.row-two {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.searchBox label {
font-weight: 500;
color: #1A1A2E;
min-width: 80px;
}
.searchBox .form-control {
padding: 6px 12px;
border: 1px solid #E2E8F0;
border-radius: 6px;
font-size: 14px;
min-width: 100px;
}
.form-inline {
display: flex;
align-items: center;
gap: 10px;
}
.form-inline .period-selects {
display: flex;
align-items: center;
gap: 10px;
}
.form-inline .period-selects .period-unit {
margin-right: 10px;
}
.btnBox {
margin-left: auto;
}
.btnBox .btn + .btn {
margin-left: 5px;
}
#printBtn {
display: none;
}
.commission-notice {
color: #dc3545;
margin-top: 30px;
line-height: 1.6;
}
.voffset3 {
margin-top: 30px;
}
.color_red {
color: #FF6B6B;
}
.color_blue {
color: #007bff;
}
.color_darkRed {
color: #8b0000;
}
.table-responsive {
overflow-x: auto;
margin-top: 30px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th {
background: #F8FAFC;
font-weight: 500;
color: #1A1A2E;
padding: 16px;
text-align: center;
border: 1px solid #E2E8F0;
}
.table td {
padding: 16px;
text-align: center;
border: 1px solid #E2E8F0;
}
.table-bordered {
border: 1px solid #E2E8F0;
}
.table-bordered th,
.table-bordered td {
border: 1px solid #E2E8F0;
}
.border-type {
border: 2px solid #dee2e6;
}
.commission-table-wrapper {
margin-top: 30px;
}
.commission-table-wrapper .table {
border: 2px solid #dee2e6;
}
.summary-row {
background-color: #fff3cd !important;
}
.summary-row td {
font-weight: 600;
}
.summary-row .summary-amount {
font-size: 16px;
color: #007bff;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-left {
text-align: left;
}
.bg-warning {
background-color: #fff3cd !important;
}
.glyphicon-search::before {
content: "🔍";
}
.info-table {
border-top: 10px solid #64748B;
width: 100%;
border-bottom: 2px solid #1A1A2E;
margin-bottom: 40px;
}
.info-table th {
text-align: left;
padding: 16px 24px;
font-weight: 600;
background: none;
border: none;
}
.info-table td {
padding: 16px 24px;
border: none;
}
.commission-table {
width: 100%;
margin-top: 40px;
}
.commission-table th,
.commission-table td {
border: 1px solid #999 !important;
padding: 16px 24px;
}
.commission-table td {
text-align: right;
}
.bg_ygreen {
background-color: aquamarine;
text-align: center;
}
.bg_skyBlue {
background-color: skyblue;
text-align: center;
}
.bg_yellow {
background-color: yellow;
text-align: center;
}
@media print {
body {
padding: 0;
background: #FFFFFF;
}
.searchBox,
.searchInfo,
#printButton,
#backButton,
#printBtn {
display: none !important;
}
@page {
size: auto;
margin: 0 0cm;
}
html {
margin: 0 0cm;
}
}
@media screen and (max-width: 1024px) {
.wrap {
padding: 16px;
}
.searchBox {
padding: 16px;
}
.searchBox.row-two {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.form-inline {
width: 100%;
flex-direction: column;
align-items: flex-start;
}
.form-inline label {
min-width: auto;
margin-bottom: 4px;
}
.form-inline .form-control {
width: 100%;
}
.form-inline--period .period-selects {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
}
.form-inline--period .period-selects .form-control {
flex: 1;
min-width: 0;
}
.form-inline--period .period-selects .period-unit {
flex-shrink: 0;
font-size: 14px;
color: #1A1A2E;
}
.btnBox {
margin-left: 0;
width: 100%;
}
.btnBox .btn {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.table-responsive {
overflow-x: scroll;
-webkit-overflow-scrolling: touch;
}
}
body.commission-print-page {
font-size: 12px;
width: 600px;
margin: 90px;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body.commission-print-page div {
position: relative;
}
body.commission-print-page table {
border-collapse: collapse;
}
body.commission-print-page table th,
body.commission-print-page table td {
padding: 5px 10px;
}
body.commission-print-page table tr {
font-size: 12px;
}
body.commission-print-page h1 {
text-align: center;
}
body.commission-print-page h1 img {
width: 300px;
}
body.commission-print-page h2 {
margin-top: 40px;
float: left;
}
body.commission-print-page p {
line-height: 1.6;
}
body.commission-print-page p[style*="float: right"] {
margin-top: 25px;
}
body.commission-print-page .btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
body.commission-print-page .btn-primary {
background: #667eea;
color: white;
}
body.commission-print-page .btn-primary:hover {
background: #5a67d8;
}
.terms-page {
min-height: calc(100vh - 140px);
background: #F8FAFC;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,60 @@
/**
* client-guard.js
*
* 클라이언트측 하드닝. 서버가 내려준 window.__CLIENT_GUARD__ 플래그에 따라 각 기능을 독립 적용한다.
* - contextmenu: 우클릭(컨텍스트 메뉴) 차단
* - devtools : 창 크기 휴리스틱으로 DevTools 열림 감지 시 화면 전체 요소 영구 삭제
*
* 주의(한계): 창 크기 휴리스틱은 오탐/미탐이 있다(줌·북마크바·사이드패널·좁은창=오탐, undock 창=미탐).
* JS 비활성화·breakpoint·DOM 편집으로 우회 가능하므로 실질 보안이 아니라 억제용 데코이다.
*/
(function () {
'use strict';
var cfg = window.__CLIENT_GUARD__ || {};
// ── 우클릭(contextmenu) 차단 ─────────────────────────────────────────
if (cfg.contextmenu) {
document.addEventListener('contextmenu', function (e) {
e.preventDefault();
}, true);
}
// ── DevTools 감지 → 화면 영구 삭제 ───────────────────────────────────
if (cfg.devtools) {
var THRESHOLD = 160; // 도킹된 DevTools 패널이 차지하는 최소 폭/높이(px) 추정값
var POLL_MS = 500;
var wiped = false;
function isDevtoolsOpen() {
var widthGap = window.outerWidth - window.innerWidth;
var heightGap = window.outerHeight - window.innerHeight;
return widthGap > THRESHOLD || heightGap > THRESHOLD;
}
function wipe() {
if (wiped) {
return;
}
wiped = true;
// 화면 전체 요소 영구 삭제 (닫아도 복구되지 않음, 새로고침 필요)
try {
document.documentElement.replaceChildren();
} catch (ignore) {
if (document.body) {
document.body.innerHTML = '';
}
}
}
function check() {
if (!wiped && isDevtoolsOpen()) {
wipe();
}
}
window.setInterval(check, POLL_MS);
window.addEventListener('resize', check);
check();
}
})();
@@ -12,6 +12,7 @@
const formEl = document.getElementById('djbCommentForm');
const inputEl = document.getElementById('djbCommentInput');
const counterEl = document.getElementById('djbCommentCharCounter');
const privateEl = document.getElementById('djbCommentPrivate');
function getCsrfToken() {
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
@@ -74,13 +75,25 @@
}
comments.forEach(function (c) {
const li = document.createElement('li');
li.className = 'djb-comment-item' + (c.adminYn === 'Y' ? ' djb-comment-item--admin' : '');
var cls = 'djb-comment-item';
if (c.adminYn === 'Y') cls += ' djb-comment-item--admin';
if (c.privatePlaceholder) cls += ' djb-comment-item--private-hidden';
else if (c.privateComment) cls += ' djb-comment-item--private';
li.className = cls;
li.setAttribute('data-comment-id', c.id);
var writerHtml = (c.adminYn === 'Y')
? '<span class="djb-comment-admin-badge">관리자</span>'
: '<span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>';
// 비공개 댓글(열람 권한 있는 경우)에는 "비공개" 태그 표시
var privateTag = (c.privateComment && !c.privatePlaceholder)
? '<span class="djb-comment-private-tag">비공개</span>' : '';
li.innerHTML = ''
+ '<div class="djb-comment-meta">'
+ ' <div class="djb-comment-meta-left">'
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
+ (c.adminYn === 'Y' ? ' <span class="djb-comment-admin-badge">관리자</span>' : '')
+ writerHtml
+ privateTag
+ ' </div>'
+ ' <div class="djb-comment-meta-right">'
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
@@ -111,9 +124,10 @@
inputEl.focus();
return;
}
var visibility = (privateEl && privateEl.checked) ? 'PRIVATE' : 'ALL';
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments', {
method: 'POST',
body: JSON.stringify({ content: content })
body: JSON.stringify({ content: content, visibility: visibility })
})
.then(function (res) {
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
@@ -122,6 +136,7 @@
})
.then(function () {
inputEl.value = '';
if (privateEl) privateEl.checked = false;
updateCounter();
load();
})
@@ -0,0 +1,35 @@
(function () {
'use strict';
// 조회수 비정상 증가 억제: sessionStorage 로 한 세션 내 중복 증가 방지.
var container = document.querySelector('.inquiry-detail-container[data-inquiry-id]');
if (!container) return;
var inquiryId = container.getAttribute('data-inquiry-id');
if (!inquiryId) return;
var viewedKey = 'inquiry_viewed_' + inquiryId;
if (sessionStorage.getItem(viewedKey)) {
return; // 이미 이번 세션에서 조회 → 증가하지 않음
}
function getCsrfToken() {
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
var meta = document.querySelector('meta[name="_csrf"]');
return meta ? meta.getAttribute('content') : '';
}
fetch('/inquiry/' + encodeURIComponent(inquiryId) + '/view', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-XSRF-TOKEN': getCsrfToken() }
})
.then(function (res) {
if (res.ok) {
sessionStorage.setItem(viewedKey, 'true');
}
})
.catch(function (err) {
console.error('[inquiry-view] increment error', err);
});
})();
@@ -0,0 +1,161 @@
/*!
* djb-swagger-i18n.js
* Swagger UI 영문 라벨을 한글로 치환하는 i18n 패치 (방법 B - DOM patch)
* 배포 경로 권장: static/plugins/swaggerUI/djb-swagger-i18n.js
* index.html 의 swagger-initializer.js 직전 또는 직후에 <script> 로 로드
*
* ?lang=en 쿼리스트링이 있으면 패치 비활성화 (원문 보기)
* 매핑 카탈로그는 01-i18n-mapping/i18n-strings.csv 와 동기 유지
*/
(function (window) {
'use strict';
// ── 비활성화 옵션 ──
if (/[?&]lang=en\b/.test(window.location.search)) return;
// ── 매핑 (key 는 디버깅용, 실제 비교는 EN 문자열 일치) ──
var I18N_MAP = {
// P0 — OAuth2 모달 본문
'auth.modal.title': { en: 'Available authorizations', ko: '사용 가능한 인증' },
'auth.scopes.description': {
en: 'Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.',
ko: '스코프는 최종 사용자를 대신하여 애플리케이션이 데이터에 접근할 수 있는 권한 수준을 부여합니다. 각 API는 하나 이상의 스코프를 선언할 수 있습니다.'
},
'auth.scopes.swagger_grant': {
en: 'API requires the following scopes. Select which ones you want to grant to Swagger UI.',
ko: '이 API 는 아래 스코프가 필요합니다. Swagger UI 에 부여할 스코프를 선택하세요.'
},
'auth.flow.clientCredentials': { en: 'OAuth2 client credentials flow', ko: 'OAuth2 클라이언트 자격증명 흐름' },
'auth.flow.implicit': { en: 'OAuth2 implicit flow', ko: 'OAuth2 암묵적 흐름' },
'auth.flow.password': { en: 'OAuth2 password flow', ko: 'OAuth2 비밀번호 흐름' },
'auth.flow.authorizationCode': { en: 'OAuth2 authorization code flow', ko: 'OAuth2 인가 코드 흐름' },
'auth.field.tokenUrl': { en: 'Token URL:', ko: '토큰 URL:' },
'auth.field.flow': { en: 'Flow:', ko: '흐름:' },
'auth.field.clientId': { en: 'client_id:', ko: '클라이언트 ID (client_id):' },
'auth.field.clientSecret': { en: 'client_secret:',ko: '클라이언트 시크릿 (client_secret):' },
'auth.field.scopes': { en: 'Scopes:', ko: '스코프:' },
'auth.action.selectAll': { en: 'select all', ko: '모두 선택' },
'auth.action.selectNone': { en: 'select none', ko: '선택 해제' },
'auth.action.authorize': { en: 'Authorize', ko: '인증' },
'auth.action.close': { en: 'Close', ko: '닫기' },
'auth.action.logout': { en: 'Logout', ko: '로그아웃' },
'auth.modal.authorized': { en: 'authorized', ko: '인증됨' },
// P1 — 기타 인증 흐름
'auth.scheme.apiKey': { en: 'API key', ko: 'API 키' },
'auth.field.name': { en: 'name:', ko: '이름:' },
'auth.field.in': { en: 'in:', ko: '위치:' },
'auth.field.value': { en: 'Value:', ko: '값:' },
'basic.field.username': { en: 'Username:',ko: '사용자 ID:' },
'basic.field.password': { en: 'Password:',ko: '비밀번호:' },
// P2 — 오퍼레이션 / 파라미터 / 응답 UI 라벨 (PoC ko-i18n.js 카탈로그와 동기)
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
'op.cancel': { en: 'Cancel', ko: '취소' },
'op.execute': { en: 'Execute', ko: '실행' },
'op.clear': { en: 'Clear', ko: '지우기' },
'op.reset': { en: 'Reset', ko: '초기화' },
'op.parameters': { en: 'Parameters', ko: '파라미터' },
'op.parameter': { en: 'Parameter', ko: '파라미터' },
'op.name': { en: 'Name', ko: '이름' },
'op.description': { en: 'Description', ko: '설명' },
'op.value': { en: 'Value', ko: '값' },
'op.type': { en: 'Type', ko: '타입' },
'op.requestBody': { en: 'Request body', ko: '요청 본문' },
'op.requestUrl': { en: 'Request URL', ko: '요청 URL' },
'op.responses': { en: 'Responses', ko: '응답' },
'op.response': { en: 'Response', ko: '응답' },
'op.responseBody': { en: 'Response body', ko: '응답 본문' },
'op.responseHeaders': { en: 'Response headers', ko: '응답 헤더' },
'op.responseCType': { en: 'Response content type', ko: '응답 컨텐츠 타입' },
'op.code': { en: 'Code', ko: '코드' },
'op.details': { en: 'Details', ko: '상세' },
'op.schema': { en: 'Schema', ko: '스키마' },
'op.schemas': { en: 'Schemas', ko: '스키마' },
'op.exampleValue': { en: 'Example Value', ko: '예시 값' },
'op.noParameters': { en: 'No parameters', ko: '파라미터 없음' },
'op.required': { en: 'required', ko: '필수' },
'op.servers': { en: 'Servers', ko: '서버' },
'op.serverResponse': { en: 'Server response', ko: '서버 응답' },
'op.loading': { en: 'Loading...', ko: '불러오는 중...' },
'op.curlUpper': { en: 'Curl', ko: 'cURL' },
'op.curlLower': { en: 'curl', ko: 'cURL' },
'op.editValue': { en: 'Edit Value', ko: '값 편집' },
'op.model': { en: 'Model', ko: '모델' },
'op.snippets': { en: 'Snippets', ko: '스니펫' },
'op.mediaType': { en: 'Media type', ko: '미디어 타입' },
'op.controlsAccept': { en: 'Controls Accept header.', ko: 'Accept 헤더를 제어합니다.' },
'op.sendEmptyValue': { en: 'Send empty value', ko: '빈 값 전송' },
'op.hide': { en: 'Hide', ko: '숨기기' },
'op.show': { en: 'Show', ko: '표시' }
};
// 빠른 lookup 을 위해 EN → KO 인덱스 사전 생성
var EN_TO_KO = Object.create(null);
Object.keys(I18N_MAP).forEach(function (k) {
var e = I18N_MAP[k];
EN_TO_KO[e.en] = e.ko;
});
// ── 치환 대상 노드 식별 + 치환 ──
function translateNode(node) {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
var raw = node.nodeValue;
if (!raw) return;
var trimmed = raw.trim();
if (!trimmed) return;
// 1) 완전 일치 (가장 안전)
if (EN_TO_KO[trimmed]) {
node.nodeValue = raw.replace(trimmed, EN_TO_KO[trimmed]);
return;
}
// 2) 라벨 colon 패턴: "Token URL:" 같이 끝이 ":" 인 짧은 라벨
// (불필요 — 1번 완전 일치로 대부분 커버)
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return;
// input placeholder / button title 도 일부 케이스에 대비
if (node.tagName === 'INPUT' && node.placeholder && EN_TO_KO[node.placeholder.trim()]) {
node.placeholder = EN_TO_KO[node.placeholder.trim()];
}
var i, child = node.childNodes, len = child.length;
for (i = 0; i < len; i++) translateNode(child[i]);
}
function translateAll() {
var root = document.getElementById('swagger-ui') || document.body;
translateNode(root);
}
// ── MutationObserver — SwaggerUI 가 동적으로 DOM 을 다시 그릴 때마다 재적용 ──
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.addedNodes && m.addedNodes.length) {
for (var j = 0; j < m.addedNodes.length; j++) translateNode(m.addedNodes[j]);
}
if (m.type === 'characterData') {
translateNode(m.target);
}
}
});
function start() {
translateAll();
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})(window);
@@ -0,0 +1,350 @@
/*!
* DjbSwaggerResponse — Swagger UI 기본 라이브 응답 표(.live-responses-table)를 CSS 로
* 숨기고, 실행(Execute)한 operation 블록 안에 커스텀 응답 패널을 세로 적층으로 렌더한다.
* (요청 스니펫 .request-snippets 은 같은 .responses-wrapper 안에 있으므로 유지)
*
* ┌─────────────────────────────────────────────┐
* │ 응답 헤더 (Table) │
* ├─────────────────────────────────────────────┤
* │ 응답 본문 (JSON Pretty / Raw) │
* ├─────────────────────────────────────────────┤
* │ 응답코드 칩 + Response Time(ms) │
* └─────────────────────────────────────────────┘
*
* 포탈 testbed 는 API 하나에 operation 이 여러 개일 수 있으므로, 전역 단일 그리드가
* 아니라 Execute 를 누른 opblock 안에 그리드를 만든다(클릭 캡처로 대상 특정).
*
* 연결:
* const ui = SwaggerUIBundle({
* responseInterceptor: function (res) { DjbSwaggerResponse.renderResponse(res); return res; }
* });
* window.ui = ui;
* DjbSwaggerResponse.attach('#swagger-ui'); // Execute 클릭 캡처 + 타이머 등록
*/
(function (global) {
"use strict";
var startTs = 0;
var targetOpblock = null;
function now() {
return (global.performance && performance.now) ? performance.now() : Date.now();
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function statusKind(status) {
if (status >= 200 && status < 300) return "ok";
if (status >= 300 && status < 400) return "warn";
if (status >= 400) return "err";
return "neutral";
}
function prettyJson(text) {
try { return JSON.stringify(JSON.parse(text), null, 2); }
catch (e) { return null; }
}
// window.ui 에서 해석된 spec(json) 조회. (snippet-panel 과 동일 방식)
function getSpecJson() {
try {
var ui = global.ui;
if (ui && typeof ui.spec === "function") {
var s = ui.spec();
if (s && typeof s.toJS === "function") {
var o = s.toJS();
return o.json || o;
}
}
} catch (e) { /* noop */ }
return null;
}
// 단일 API 페이지 전제(첫 path/method)로 operation 반환.
function getSingleOperation(spec) {
var paths = (spec && spec.paths) ? spec.paths : {};
var pk = Object.keys(paths)[0];
if (!pk) return null;
var ops = paths[pk];
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
if (!mk) return null;
return { path: pk, method: mk, operation: ops[mk] };
}
// opblock 안 파라미터 입력값(header/query) 읽기.
function readParamInput(opblock, name, where) {
var scope = opblock || document;
var row = scope.querySelector('tr[data-param-name="' + name + '"][data-param-in="' + where + '"]');
if (row) {
var input = row.querySelector('input[type="text"], input:not([type]), textarea');
if (input && input.value) return input.value;
}
return undefined;
}
// Execute 직전 상태에서 검증용 요청 객체를 조립(헤더/쿼리/본문).
function buildValidationRequest(opblock, opInfo) {
var op = opInfo.operation;
var headers = {};
var query = [];
(op.parameters || []).forEach(function (p) {
if (p.in === "header") {
var hv = readParamInput(opblock, p.name, "header");
if (hv !== undefined) headers[p.name] = hv;
} else if (p.in === "query") {
var qv = readParamInput(opblock, p.name, "query");
if (qv !== undefined) query.push(encodeURIComponent(p.name) + "=" + encodeURIComponent(qv));
}
});
var body = "";
if (op.requestBody) {
headers["Content-Type"] = headers["Content-Type"] || "application/json";
var ta = (opblock || document).querySelector("textarea.body-param__text");
if (ta) body = ta.value;
}
var url = "http://_local" + opInfo.path + (query.length ? "?" + query.join("&") : "");
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
}
function renderEmpty() {
return ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ '<div class="empty">요청 중…</div></div>'
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
+ '<div class="empty">요청 중…</div></div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip neutral">대기</span>'
+ '<span class="time">Response Time: -</span>'
+ '</div>';
}
function renderHeadersTable(headers) {
var keys = Object.keys(headers || {});
if (!keys.length) return '<div class="empty">헤더가 없습니다.</div>';
var rows = keys.map(function (k) {
return '<tr><td class="hdr-name">' + escapeHtml(k) + '</td>'
+ '<td class="hdr-val">' + escapeHtml(headers[k]) + '</td></tr>';
}).join("");
return '<table class="hdr-table"><thead><tr><th>이름</th><th>값</th></tr></thead>'
+ '<tbody>' + rows + '</tbody></table>';
}
function bodyTabs(rawText) {
var pretty = prettyJson(rawText);
var hasJson = pretty != null;
var jsonTab = hasJson ? '<button type="button" class="body-tab active" data-tab="json">JSON (Pretty)</button>' : "";
var rawTab = '<button type="button" class="body-tab' + (hasJson ? "" : " active") + '" data-tab="raw">Raw</button>';
var jsonPanel = hasJson ? '<pre class="body-panel mono active" data-tab="json">' + escapeHtml(pretty) + '</pre>' : "";
var rawPanel = '<pre class="body-panel mono' + (hasJson ? "" : " active") + '" data-tab="raw">' + escapeHtml(rawText || "") + '</pre>';
return '<div class="body-tabs" role="tablist">' + jsonTab + rawTab + '</div>'
+ '<div class="body-panels">' + jsonPanel + rawPanel + '</div>';
}
function attachTabHandlers(root) {
if (root.__djbTabBound) return;
root.__djbTabBound = true;
root.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest(".body-tab");
if (!btn) return;
var panels = root.querySelectorAll(".body-panel");
var tabs = root.querySelectorAll(".body-tab");
tabs.forEach(function (t) { t.classList.toggle("active", t === btn); });
var which = btn.getAttribute("data-tab");
panels.forEach(function (p) { p.classList.toggle("active", p.getAttribute("data-tab") === which); });
});
}
// 대상 opblock(없으면 열려있는/첫 opblock) 안 .opblock-body 에 그리드 1개 확보.
// 항상 opblock-body 최하단으로 이동시킨다: Execute 시점엔 responses-wrapper(요청
// 스니펫)가 아직 없어 그리드가 그 위에 붙지만, 응답 후 재호출 시 최하단으로 옮겨
// "Execute → 요청 스니펫 → 응답 그리드" 순서를 보장한다.
function gridFor(opblock) {
var host = opblock
|| document.querySelector("#swagger-ui .opblock.is-open")
|| document.querySelector("#swagger-ui .opblock");
if (!host) return null;
var body = host.querySelector(".opblock-body");
if (!body) return null; // 접힘 상태에는 마운트하지 않음 (bare opblock 오배치 방지)
var grid = body.querySelector(".djb-response-grid");
if (!grid) {
grid = document.createElement("div");
grid.className = "djb-response-grid djb-empty";
grid.innerHTML = renderEmpty();
attachTabHandlers(grid);
}
body.appendChild(grid); // 기존 노드면 최하단으로 이동
return grid;
}
// 검증 실패 렌더: 네트워크 요청이 발생하지 않은 상태. djb-error 스타일 그리드.
function renderValidationErrors(errors) {
var grid = gridFor(targetOpblock);
if (!grid) return;
var items = (errors || []).map(function (er) {
return '<li class="verr-item">'
+ '<span class="verr-path">' + escapeHtml(er.path) + '</span> '
+ '<span class="verr-msg">' + escapeHtml(er.message) + '</span>'
+ (er.value !== undefined ? ' <code class="verr-val mono">' + escapeHtml(JSON.stringify(er.value)) + '</code>' : "")
+ '</li>';
}).join("");
grid.classList.remove("djb-empty");
grid.classList.add("djb-error");
grid.innerHTML = ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ '<div class="empty">검증 실패 — 요청이 전송되지 않았습니다.</div></div>'
+ '<div class="cell-body"><div class="cell-title">검증 실패 (' + (errors || []).length + '건)</div>'
+ '<ul class="verr-list">' + items + '</ul></div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip err">검증실패</span>'
+ '<span class="time">Response Time: -</span>'
+ '</div>';
}
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
function renderNetworkError(msg) {
var grid = gridFor(targetOpblock);
if (!grid) return;
grid.classList.remove("djb-empty");
grid.classList.add("djb-error");
grid.innerHTML = ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ '<div class="empty">응답을 받지 못했습니다.</div></div>'
+ '<div class="cell-body"><div class="cell-title">네트워크 오류</div>'
+ '<pre class="body-panel mono active" data-tab="raw">요청 실패: ' + escapeHtml(msg) + '</pre></div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip err">네트워크 오류</span>'
+ '<span class="time">Response Time: -</span>'
+ '</div>';
}
// Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
function onExecuteClick(e) {
var t = e.target;
if (!t || !t.closest) return;
var btn = t.closest(".btn.execute");
if (!btn) return;
targetOpblock = btn.closest(".opblock");
// ── 요청 파라미터/본문 검증 (검증기 로드된 경우) ──
if (global.DjbSwaggerValidator) {
try {
var spec = getSpecJson();
var opInfo = spec && getSingleOperation(spec);
if (opInfo) {
var req = buildValidationRequest(targetOpblock, opInfo);
var result = global.DjbSwaggerValidator.validateOperation(opInfo.operation, req, spec);
if (!result.ok) {
e.preventDefault();
e.stopImmediatePropagation();
renderValidationErrors(result.errors);
return;
}
}
} catch (err) { console.error("djb validation error:", err); }
}
startTs = now();
var grid = gridFor(targetOpblock);
if (grid) {
grid.classList.add("djb-empty");
grid.classList.remove("djb-error");
grid.innerHTML = renderEmpty();
}
}
/**
* @param {Object} res Swagger UI responseInterceptor 결과
* { status, statusText, headers, text, data, ... }
*/
function renderResponse(res) {
if (!res) return;
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
var ms = Math.max(0, Math.round(now() - startTs));
var status = res.status || 0;
var statusText = res.statusText || "";
var headers = res.headers || {};
var rawText = (typeof res.text === "string") ? res.text
: (typeof res.data === "string") ? res.data
: (res.data != null ? JSON.stringify(res.data) : "");
var op = targetOpblock;
// Swagger(React) 가 응답 렌더로 opblock-body 를 재조정한 뒤 append 되도록 지연.
setTimeout(function () {
var grid = gridFor(op);
if (!grid) return;
var kind = statusKind(status);
grid.classList.remove("djb-empty", "djb-error");
grid.innerHTML = ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ renderHeadersTable(headers) + '</div>'
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
+ bodyTabs(rawText) + '</div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip ' + kind + '">응답코드: ' + escapeHtml(status)
+ (statusText ? " " + escapeHtml(statusText) : "") + '</span>'
+ '<span class="time">Response Time: ' + ms + ' ms</span>'
+ '</div>';
attachTabHandlers(grid);
}, 0);
}
// fetch 실패(네트워크/CORS/타임아웃)는 responseInterceptor 로 전달되지 않고 store 의
// spec.responses[path][method] 에 { error:true, err } 로만 저장된다. store 를 구독해
// 사용자에게 네트워크 오류 그리드로 보여준다. (PoC bootstrap 의 getStore().subscribe 이식)
function watchNetworkErrors(ui) {
if (!ui || typeof ui.getStore !== "function" || ui.__djbNetWatch) return;
ui.__djbNetWatch = true;
var rendered = {};
ui.getStore().subscribe(function () {
try {
var respMap = ui.getState().getIn(["spec", "responses"]);
if (!respMap || typeof respMap.forEach !== "function") return;
respMap.forEach(function (methods, path) {
if (!methods || typeof methods.forEach !== "function") return;
methods.forEach(function (resp, method) {
var key = path + " " + method;
var hasError = resp && (resp.get ? resp.get("error") : resp.error);
if (hasError) {
if (rendered[key]) return;
rendered[key] = true;
var err = resp.get ? resp.get("err") : resp.err;
var msg = (err && (err.message || (err.get && err.get("message"))))
|| (err ? String(err) : "응답을 받지 못했습니다.");
renderNetworkError(String(msg));
} else {
rendered[key] = false;
}
});
});
} catch (e) { /* ignore */ }
});
}
// #swagger-ui 는 API 전환 시에도 element 자체는 유지되므로 캡처 리스너 1회 등록으로 충분.
function attach(rootSel) {
var root = document.querySelector(rootSel || "#swagger-ui");
if (!root || root.__djbRespBound) return;
root.__djbRespBound = true;
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보 + 검증 차단
root.addEventListener("click", onExecuteClick, true);
// window.ui 준비 시 네트워크 오류 감시 등록
watchNetworkErrors(global.ui);
}
global.DjbSwaggerResponse = {
attach: attach,
renderResponse: renderResponse,
renderValidationErrors: renderValidationErrors,
renderNetworkError: renderNetworkError,
watchNetworkErrors: watchNetworkErrors
};
})(window);
@@ -0,0 +1,265 @@
/*!
* DjbSwaggerSnippetPanel — Execute 없이 페이지 진입 즉시 요청 스니펫을 보여주는 상시 패널.
* (PoC rinjae-works/swagger-poc/js/snippet-panel.js 를 포탈용으로 이식)
*
* Swagger UI 5 의 내장 .request-snippets 는 Execute 이후에만, 그리고 응답영역 안에
* 렌더되므로 CSS 로 숨기고, 동일한 window.SnippetGenerators 로 직접 패널을 만들어
* operation 의 Execute 버튼(.btn-group) 바로 아래에 마운트한다. Body/헤더 변경 시 자동 갱신.
*
* 전제: 이 페이지는 항상 1개 API(단일 operation) 노출 — spec 의 첫 path/method 사용.
* spec 은 window.ui 에서 지연 조회한다.
*
* 연결: SwaggerUIBundle 의 onComplete 에서 DjbSwaggerSnippetPanel.mount() 호출.
*/
(function (global) {
"use strict";
var TABS = [
{ key: "curl_bash", title: "cURL (Bash)", gen: "curlBash" },
{ key: "curl_powershell", title: "cURL (PowerShell)", gen: "curlPwsh" },
{ key: "curl_cmd", title: "cURL (CMD)", gen: "curlCmd" },
{ key: "csharp", title: "C#", gen: "csharp" },
{ key: "ecma5", title: "ECMA5 (JavaScript)", gen: "ecma5" },
{ key: "java", title: "Java", gen: "javaOkhttp" },
{ key: "python", title: "Python", gen: "python" }
];
var activeKey = "curl_bash";
var rootSel = "#swagger-ui";
var observer = null;
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// window.ui 에서 해석된 spec(json) 조회.
function getSpecJson() {
try {
var ui = global.ui;
if (ui && typeof ui.spec === "function") {
var s = ui.spec();
if (s && typeof s.toJS === "function") {
var o = s.toJS();
return o.json || o;
}
}
} catch (e) { /* noop */ }
return null;
}
// Path 1 + Method 1 전제 하에 단일 operation 반환.
function getSingleOperation(spec) {
var paths = (spec && spec.paths) ? spec.paths : {};
var pk = Object.keys(paths)[0];
if (!pk) return null;
var ops = paths[pk];
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
if (!mk) return null;
return { path: pk, method: mk, operation: ops[mk] };
}
function readHeaderInput(name) {
var row = document.querySelector('tr[data-param-name="' + name + '"][data-param-in="header"]');
if (row) {
var input = row.querySelector('input[type="text"], input:not([type])');
if (input && input.value) return input.value;
}
return null;
}
// Authorize 모달 사용 후에만 표시되는 인증 헤더 추출.
function readAuthHeaders() {
var headers = {};
try {
var ui = global.ui;
if (!ui || !ui.authSelectors) return headers;
var auths = ui.authSelectors.authorized();
if (!auths) return headers;
var js = auths.toJS();
Object.keys(js).forEach(function (key) {
var a = js[key];
if (!a || !a.schema) return;
var scheme = a.schema;
if (scheme.type === "oauth2") {
var tok = a.token && a.token.access_token;
if (tok) headers["Authorization"] = "Bearer " + tok;
} else if (scheme.type === "apiKey" && scheme.in === "header") {
if (a.value) headers[scheme.name] = a.value;
} else if (scheme.type === "http" && scheme.scheme === "bearer") {
if (a.value) headers["Authorization"] = "Bearer " + a.value;
}
});
} catch (e) { /* noop */ }
return headers;
}
function buildRequest() {
var spec = getSpecJson();
var opInfo = spec && getSingleOperation(spec);
if (!opInfo) return null;
var op = opInfo.operation;
var server = (spec.servers && spec.servers[0] && spec.servers[0].url) || "";
var url = server + opInfo.path;
var headers = {};
(op.parameters || []).forEach(function (p) {
if (p.in !== "header") return;
var val = readHeaderInput(p.name)
|| (p.example !== undefined ? String(p.example) : null)
|| (p.schema && p.schema.example !== undefined ? String(p.schema.example) : null);
if (val) headers[p.name] = val;
});
var body = "";
if (op.requestBody && op.requestBody.content) {
var cts = Object.keys(op.requestBody.content);
var ct = cts[0] || "application/json";
headers["Content-Type"] = ct;
var ta = document.querySelector(rootSel + " textarea.body-param__text");
if (ta && ta.value) {
body = ta.value;
} else if (op.requestBody.content[ct] && op.requestBody.content[ct].example !== undefined) {
try { body = JSON.stringify(op.requestBody.content[ct].example, null, 2); } catch (e) { body = ""; }
}
}
headers["accept"] = headers["accept"] || "application/json";
var auth = readAuthHeaders();
Object.keys(auth).forEach(function (k) { headers[k] = auth[k]; });
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
}
function panelEl() { return document.querySelector("#djb-snippet-panel"); }
function renderInto(el) {
if (!el) return;
var gens = global.SnippetGenerators;
var code;
if (!gens) {
code = "// 스니펫 생성기 로드 대기…";
} else {
try {
var req = buildRequest();
if (!req) { code = "// 스펙 로딩 중…"; }
else {
var active = TABS.filter(function (t) { return t.key === activeKey; })[0] || TABS[0];
code = gens[active.gen](req);
}
} catch (e) { code = "// 스니펫 생성 오류: " + e.message; }
}
var tabsHtml = TABS.map(function (t) {
return '<button type="button" class="djb-sn-tab ' + (t.key === activeKey ? "active" : "") + '" data-key="' + t.key + '">' + t.title + '</button>';
}).join("");
el.innerHTML =
'<div class="djb-sn-header">'
+ '<h4>요청 스니펫</h4>'
+ '<span class="djb-sn-hint">(실행 없이 즉시 미리보기 — Body / 헤더 변경 시 자동 갱신)</span>'
+ '<button type="button" class="djb-sn-copy" data-action="copy">복사</button>'
+ '</div>'
+ '<div class="djb-sn-tabs" role="tablist">' + tabsHtml + '</div>'
+ '<pre class="djb-sn-code mono">' + escapeHtml(code) + '</pre>';
}
// 옵저버 재진입(무한 루프) 방지: 우리 DOM 변경 중에는 관찰 일시 중지.
function withObserverPaused(fn) {
if (observer) observer.disconnect();
try { fn(); }
finally {
if (observer) {
var root = document.querySelector(rootSel);
if (root) observer.observe(root, { childList: true, subtree: true });
}
}
}
// Execute 영역 바로 아래 = Swagger UI 폼 하단에 패널 확보.
// - 앵커는 opblock-body '직속' 자식만 본다: 실행 전에는 .execute-wrapper, 실행 후에는
// Swagger UI 5 가 이를 .btn-group(실행+초기화 버튼군)으로 '교체'하므로 둘 다 허용한다.
// (execute-wrapper 만 앵커로 쓰면 실행 후 앵커 소실 → 패널이 제거되고 재생성 안 됨.)
// - 중첩 .btn-group(파라미터 섹션 헤더의 try-out '취소' 버튼군)은 직속 자식이 아니라
// 건너뛴다: 그걸 앵커로 잡으면 flex 헤더 안에 삽입돼 "파라미터" 라벨이 찌부된다.
// - operation 이 접혀 opblock-body/Execute 영역이 없으면: 잘못 남은 패널을 제거하고 중단.
// 다음 펼침 때 재생성.
// - 이미 있으나 위치가 어긋나면: anchor 바로 뒤로 재배치.
function ensureMounted() {
var op = document.querySelector(rootSel + " .opblock.is-open")
|| document.querySelector(rootSel + " .opblock");
var body = op && op.querySelector(".opblock-body");
var anchor = null;
if (body) {
for (var i = 0; i < body.children.length; i++) {
var kid = body.children[i];
if (kid.classList.contains("execute-wrapper") || kid.classList.contains("btn-group")) {
anchor = kid;
break;
}
}
}
var existing = panelEl();
if (!body || !anchor) {
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
return;
}
if (existing) {
if (existing.parentNode !== anchor.parentNode || existing.previousElementSibling !== anchor) {
anchor.parentNode.insertBefore(existing, anchor.nextSibling);
}
return;
}
var panel = document.createElement("div");
panel.id = "djb-snippet-panel";
panel.className = "djb-snippet-panel";
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
renderInto(panel);
}
function onClick(e) {
var tab = e.target.closest && e.target.closest("#djb-snippet-panel .djb-sn-tab");
if (tab) {
activeKey = tab.getAttribute("data-key");
withObserverPaused(function () { renderInto(panelEl()); });
return;
}
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
if (copy) {
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
if (code && global.navigator && navigator.clipboard) {
navigator.clipboard.writeText(code.innerText).then(function () {
copy.textContent = "복사됨";
setTimeout(function () { copy.textContent = "복사"; }, 1200);
});
}
}
}
function onInput(e) {
var t = e.target;
if (!t || !t.matches) return;
if (t.matches("textarea.body-param__text") || t.matches("tr[data-param-name] input")) {
withObserverPaused(function () { renderInto(panelEl()); });
}
}
function mount(opts) {
rootSel = (opts && opts.rootSel) || "#swagger-ui";
var root = document.querySelector(rootSel);
if (!root) return;
if (!root.__djbSnBound) {
root.__djbSnBound = true;
root.addEventListener("click", onClick);
root.addEventListener("input", onInput);
observer = new MutationObserver(function () {
withObserverPaused(function () { ensureMounted(); });
});
observer.observe(root, { childList: true, subtree: true });
}
withObserverPaused(function () { ensureMounted(); });
}
global.DjbSwaggerSnippetPanel = { mount: mount, render: function () { renderInto(panelEl()); } };
})(window);
@@ -0,0 +1,223 @@
/*!
* DjbSwaggerSnippets — Swagger UI 의 SnippetGeneratorPlugin 에 등록되는 7개 언어
* 클라이언트 코드 생성기. 서버측 /api/sample_code/{language} 호출을 대체한다.
*
* cURL (Bash) / cURL (PowerShell) / cURL (CMD) / C# / ECMA5 (JS) / Java / Python
*
* 입력: Swagger UI requestSnippetGenerator 가 넘기는 Immutable request.
* .toJS() 로 plain object 변환 후 url/method/headers/body 사용.
* (showMutatedRequest:false 이므로 프록시 URL 이 아닌 실제 대상 요청이 넘어온다)
* 출력: string (코드 본문)
*
* 전역: window.SnippetGeneratorPlugin (Swagger plugins 배열에 등록),
* window.SnippetGenerators (개별 생성 함수 접근용)
*/
(function (global) {
"use strict";
function unwrap(req) {
if (!req) return {};
if (typeof req.toJS === "function") return req.toJS();
return req;
}
function getCt(headers) {
for (const k of Object.keys(headers || {})) {
if (k.toLowerCase() === "content-type") return headers[k];
}
return null;
}
function bodyAsString(body) {
if (body === undefined || body === null) return "";
if (typeof body === "string") return body;
try { return JSON.stringify(body); } catch (e) { return String(body); }
}
function escapeSingleQuote(s) { return String(s).replace(/'/g, "'\\''"); }
function escapeDoubleQuote(s) { return String(s).replace(/"/g, '\\"'); }
// ─── cURL (Bash) ────────────────────────────────────────────────────────────
function curlBash(reqRaw) {
const r = unwrap(reqRaw);
const lines = [`curl --location --request ${r.method || "GET"} '${escapeSingleQuote(r.url)}'`];
const headers = r.headers || {};
for (const k of Object.keys(headers)) {
lines.push(` --header '${escapeSingleQuote(k)}: ${escapeSingleQuote(headers[k])}'`);
}
const body = bodyAsString(r.body);
if (body) {
lines.push(` --data-raw '${escapeSingleQuote(body)}'`);
}
return lines.join(" \\\n");
}
// ─── cURL (PowerShell) ─────────────────────────────────────────────────────
function curlPwsh(reqRaw) {
const r = unwrap(reqRaw);
// 백틱(`) 줄 연속자
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
const headers = r.headers || {};
for (const k of Object.keys(headers)) {
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
}
const body = bodyAsString(r.body);
if (body) {
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
}
return segments.join(" `\n");
}
// ─── cURL (Windows CMD) ────────────────────────────────────────────────────
function curlCmd(reqRaw) {
const r = unwrap(reqRaw);
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
const headers = r.headers || {};
for (const k of Object.keys(headers)) {
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
}
const body = bodyAsString(r.body);
if (body) {
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
}
return segments.join(" ^\n");
}
// ─── C# (RestSharp) ─────────────────────────────────────────────────────────
function csharp(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toUpperCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const lines = [
`using RestSharp;`,
``,
`var client = new RestClient("${escapeDoubleQuote(r.url)}");`,
`var request = new RestRequest(Method.${method});`,
];
for (const k of Object.keys(headers)) {
lines.push(`request.AddHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
}
if (body) {
const ct = getCt(headers) || "application/json";
lines.push(`request.AddParameter("${escapeDoubleQuote(ct)}", @"${body.replace(/"/g, '""')}", ParameterType.RequestBody);`);
}
lines.push(`IRestResponse response = client.Execute(request);`);
lines.push(`Console.WriteLine(response.Content);`);
return lines.join("\n");
}
// ─── ECMA5 / JavaScript (XHR) ──────────────────────────────────────────────
function ecma5(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toUpperCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const lines = [
`var xhr = new XMLHttpRequest();`,
`xhr.withCredentials = false;`,
``,
`xhr.addEventListener("readystatechange", function () {`,
` if (this.readyState === 4) {`,
` console.log(this.responseText);`,
` }`,
`});`,
``,
`xhr.open("${method}", "${escapeDoubleQuote(r.url)}");`,
];
for (const k of Object.keys(headers)) {
lines.push(`xhr.setRequestHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
}
if (body) {
// ES5 라 template literal 사용 X — JSON.stringify 로 안전한 문자열 리터럴 생성
lines.push(`xhr.send(${JSON.stringify(body)});`);
} else {
lines.push(`xhr.send(null);`);
}
return lines.join("\n");
}
// ─── Java (OkHttp) ─────────────────────────────────────────────────────────
function javaOkhttp(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toUpperCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const ct = getCt(headers) || "application/json";
const lines = [
`OkHttpClient client = new OkHttpClient().newBuilder().build();`,
`MediaType mediaType = MediaType.parse("${escapeDoubleQuote(ct)}");`,
];
if (body) {
lines.push(`RequestBody body = RequestBody.create(mediaType, "${escapeDoubleQuote(body)}");`);
}
lines.push(`Request request = new Request.Builder()`);
lines.push(` .url("${escapeDoubleQuote(r.url)}")`);
if (body) {
lines.push(` .method("${method}", body)`);
} else {
lines.push(` .method("${method}", null)`);
}
for (const k of Object.keys(headers)) {
lines.push(` .addHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}")`);
}
lines.push(` .build();`);
lines.push(`Response response = client.newCall(request).execute();`);
return lines.join("\n");
}
// ─── Python (requests) ─────────────────────────────────────────────────────
function python(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toLowerCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const headerEntries = Object.keys(headers).map(
k => ` "${escapeDoubleQuote(k)}": "${escapeDoubleQuote(headers[k])}"`
);
const lines = [
`import requests`,
``,
`url = "${escapeDoubleQuote(r.url)}"`,
``,
];
if (body) {
lines.push(`payload = ${JSON.stringify(body)}`);
}
lines.push(`headers = {`);
if (headerEntries.length) lines.push(headerEntries.join(",\n"));
lines.push(`}`);
lines.push(``);
if (body) {
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers, data=payload)`);
} else {
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers)`);
}
lines.push(`print(response.text)`);
return lines.join("\n");
}
// ─── Swagger UI 플러그인 등록 페이로드 ──────────────────────────────────────
const SnippetGeneratorPlugin = function () {
return {
fn: {
// curl_bash / curl_powershell / curl_cmd 는 Swagger UI 5 내장 기본 키와 동일하게
// 맞춰 내장 생성기를 덮어쓴다. (curl_pwsh 등 다른 키를 쓰면 내장 PowerShell 탭이
// 중복 표시됨)
requestSnippetGenerator_curl_bash: curlBash,
requestSnippetGenerator_curl_powershell: curlPwsh,
requestSnippetGenerator_curl_cmd: curlCmd,
requestSnippetGenerator_csharp: csharp,
requestSnippetGenerator_ecma5: ecma5,
requestSnippetGenerator_java: javaOkhttp,
requestSnippetGenerator_python: python
}
};
};
global.SnippetGeneratorPlugin = SnippetGeneratorPlugin;
global.SnippetGenerators = {
curlBash, curlPwsh, curlCmd, csharp, ecma5, javaOkhttp, python
};
})(window);
@@ -0,0 +1,314 @@
/* ─────────────────────────────────────────────────────────────────────────── */
/* DJB Swagger Testbed overlay */
/* - 기본 라이브 응답 표(.live-responses-table) 숨김 (스니펫은 유지) */
/* - 실행한 operation 안에 세로 적층 커스텀 응답 패널(.djb-response-grid) */
/* - D2Coding 우선 monospace fallback */
/* 포탈 통합용. PoC(rinjae-works/swagger-poc/css/overlay.css) 에서 응답 파트만 */
/* 발췌·클래스화(단일 ID → 다중 op 대응). */
/* ─────────────────────────────────────────────────────────────────────────── */
/* D2Coding 은 시스템에 설치된 경우에만 사용 (local()). 미설치 시 fallback 체인 사용. */
@font-face {
font-family: "D2Coding";
src: local("D2Coding"),
local("D2Coding-Regular"),
local("D2Coding ligature");
font-display: swap;
}
:root {
--djb-mono: "D2Coding", "Menlo", "Consolas", "Courier New", monospace;
--djb-border: #d0d7de;
--djb-bg: #ffffff;
--djb-bg-alt: #f6f8fa;
--djb-text: #1f2328;
--djb-text-muted: #6e7781;
--djb-ok: #1f883d;
--djb-warn: #bf8700;
--djb-err: #cf222e;
--djb-accent: #0969da;
}
/* ─── Swagger UI 기본 응답영역: 문서화 응답 샘플만 노출 ────────────────────────
응답 영역(.responses-wrapper)은 표시하되(= 상태코드별 응답 예제/스키마 = "응답 샘플"),
실행 결과 라이브 블록은 숨긴다:
- 요청 스니펫(.request-snippets) → 상시 다크 패널(#djb-snippet-panel)로 대체
- 라이브 응답(Curl/Request URL/Server response + .live-responses-table)
= .responses-inner 의 직접 자식 <div> → 커스텀 그리드(.djb-response-grid)로 대체
문서화 응답 예제는 .responses-inner > table.responses-table 이라 <div> 숨김과 구분됨.
최종 순서: 파라미터 → 요청 본문 → Execute → 요청 스니펫(패널) → 응답 샘플(표) → 응답 그리드. */
.swagger-ui .request-snippets { display: none !important; }
.swagger-ui .responses-inner > div { display: none !important; }
.swagger-ui .live-responses-table { display: none !important; }
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
.swagger-ui #djb-snippet-panel {
margin: 16px;
padding: 12px 16px 16px;
background: var(--djb-bg);
border: 1px solid var(--djb-border);
border-radius: 8px;
}
.swagger-ui #djb-snippet-panel .djb-sn-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.swagger-ui #djb-snippet-panel .djb-sn-header h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--djb-text);
}
.swagger-ui #djb-snippet-panel .djb-sn-hint {
color: var(--djb-text-muted);
font-size: 11px;
}
.swagger-ui #djb-snippet-panel .djb-sn-copy {
margin-left: auto;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
background: var(--djb-bg-alt);
border: 1px solid var(--djb-border);
border-radius: 4px;
cursor: pointer;
color: var(--djb-text);
}
.swagger-ui #djb-snippet-panel .djb-sn-copy:hover { background: #eaeef2; }
.swagger-ui #djb-snippet-panel .djb-sn-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
border-bottom: 1px solid var(--djb-border);
}
.swagger-ui #djb-snippet-panel .djb-sn-tab {
background: transparent;
border: 1px solid transparent;
border-bottom: none;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--djb-text-muted);
cursor: pointer;
border-radius: 4px 4px 0 0;
white-space: nowrap;
}
.swagger-ui #djb-snippet-panel .djb-sn-tab:hover { color: var(--djb-text); }
.swagger-ui #djb-snippet-panel .djb-sn-tab.active {
color: var(--djb-accent);
border-color: var(--djb-border);
background: var(--djb-bg);
margin-bottom: -1px;
}
.swagger-ui #djb-snippet-panel .djb-sn-code {
margin: 0;
padding: 12px;
background: #0d1117;
color: #e6edf3;
border-radius: 6px;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-family: var(--djb-mono);
font-size: 12.5px;
line-height: 1.55;
}
/* 한글 라벨이 길어도 깨지지 않도록 */
.swagger-ui .opblock .opblock-summary-path,
.swagger-ui .opblock-tag,
.swagger-ui table thead tr th,
.swagger-ui .parameter__name,
.swagger-ui .parameter__type {
word-break: keep-all;
}
/* ─── 커스텀 응답 그리드: 헤더 → 본문 → 메타 세로 적층 (opblock 마다 1개) ── */
.swagger-ui .djb-response-grid {
margin: 16px;
padding: 16px;
background: var(--djb-bg);
border: 1px solid var(--djb-border);
border-radius: 8px;
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
"headers"
"body"
"meta";
gap: 12px;
color: var(--djb-text);
}
.swagger-ui .djb-response-grid.djb-error { border-color: var(--djb-err); background: #fff8f8; }
.swagger-ui .djb-response-grid .cell-headers { grid-area: headers; min-width: 0; }
.swagger-ui .djb-response-grid .cell-body { grid-area: body; min-width: 0; }
.swagger-ui .djb-response-grid .cell-meta {
grid-area: meta;
display: flex;
align-items: center;
gap: 16px;
padding-top: 8px;
border-top: 1px dashed var(--djb-border);
}
.swagger-ui .djb-response-grid .cell-title {
font-size: 12px;
font-weight: 700;
color: var(--djb-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.swagger-ui .djb-response-grid .empty {
color: var(--djb-text-muted);
font-size: 13px;
padding: 8px 0;
}
/* Header 테이블 */
.swagger-ui .djb-response-grid .hdr-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.swagger-ui .djb-response-grid .hdr-table th,
.swagger-ui .djb-response-grid .hdr-table td {
border-bottom: 1px solid var(--djb-border);
padding: 6px 8px;
text-align: left;
vertical-align: top;
word-break: break-all;
}
.swagger-ui .djb-response-grid .hdr-table thead th {
background: var(--djb-bg-alt);
font-weight: 600;
}
.swagger-ui .djb-response-grid .hdr-table td.hdr-name {
font-weight: 600;
color: var(--djb-text);
width: 40%;
}
.swagger-ui .djb-response-grid .hdr-table td.hdr-val {
font-family: var(--djb-mono);
font-size: 12px;
color: var(--djb-text);
}
/* Body 탭 */
.swagger-ui .djb-response-grid .body-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--djb-border);
margin-bottom: 8px;
}
.swagger-ui .djb-response-grid .body-tab {
background: transparent;
border: 1px solid transparent;
border-bottom: none;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--djb-text-muted);
cursor: pointer;
border-radius: 4px 4px 0 0;
}
.swagger-ui .djb-response-grid .body-tab:hover { color: var(--djb-text); }
.swagger-ui .djb-response-grid .body-tab.active {
color: var(--djb-accent);
border-color: var(--djb-border);
background: var(--djb-bg);
margin-bottom: -1px;
}
.swagger-ui .djb-response-grid .body-panels {
position: relative;
min-height: 80px;
}
.swagger-ui .djb-response-grid .body-panel {
display: none;
margin: 0;
padding: 12px;
background: #0d1117;
color: #e6edf3;
border-radius: 6px;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 12.5px;
line-height: 1.55;
}
.swagger-ui .djb-response-grid .body-panel.active { display: block; }
.swagger-ui .djb-response-grid .mono { font-family: var(--djb-mono); }
/* Meta 칩 / 시간 */
.swagger-ui .djb-response-grid .status-chip {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
font-family: var(--djb-mono);
color: #fff;
}
.swagger-ui .djb-response-grid .status-chip.ok { background: var(--djb-ok); }
.swagger-ui .djb-response-grid .status-chip.warn { background: var(--djb-warn); }
.swagger-ui .djb-response-grid .status-chip.err { background: var(--djb-err); }
.swagger-ui .djb-response-grid .status-chip.neutral { background: var(--djb-text-muted); }
.swagger-ui .djb-response-grid .time {
font-family: var(--djb-mono);
font-size: 12px;
color: var(--djb-text-muted);
}
/* ─── 검증 실패 목록 (djb-swagger-validator.js) ─────────────────────────────── */
.swagger-ui .djb-response-grid .verr-list {
list-style: none;
margin: 0;
padding: 0;
}
.swagger-ui .djb-response-grid .verr-item {
padding: 8px 10px;
margin-bottom: 6px;
background: #fff8f8;
border: 1px solid #ffd7d5;
border-left: 3px solid var(--djb-err);
border-radius: 4px;
font-size: 13px;
line-height: 1.5;
}
.swagger-ui .djb-response-grid .verr-path {
font-family: var(--djb-mono);
font-weight: 700;
color: var(--djb-err);
}
.swagger-ui .djb-response-grid .verr-msg { color: var(--djb-text); }
.swagger-ui .djb-response-grid .verr-val {
display: inline-block;
margin-top: 2px;
padding: 1px 6px;
background: var(--djb-bg-alt);
border: 1px solid var(--djb-border);
border-radius: 3px;
color: var(--djb-text-muted);
font-size: 12px;
}
/* ─── 다크 코드 패널 텍스트 선택 가독성 ──────────────────────────────────────
기본 ::selection 색이 어두운 배경(#0d1117)과 겹쳐 선택한 글자가 안 보이므로,
선택 영역 배경/글자색을 명시한다. (요청 스니펫 코드 + 응답 본문 패널) */
.swagger-ui #djb-snippet-panel .djb-sn-code::selection,
.swagger-ui #djb-snippet-panel .djb-sn-code ::selection,
.swagger-ui .djb-response-grid .body-panel::selection,
.swagger-ui .djb-response-grid .body-panel ::selection {
background: #2f5c9e;
color: #ffffff;
}
@@ -0,0 +1,202 @@
/*!
* DjbSwaggerValidator Execute 전에 요청 파라미터(header/query) 요청 본문(body)
* OpenAPI 스키마로 검증하는 경량 JSON Schema 검증기.
* (PoC rinjae-works/swagger-poc/js/validator.js 포탈용으로 이식)
*
* 지원 키워드: type, required, properties, items, $ref, maxLength, minLength,
* maximum, minimum, pattern, enum
* 지원 type: string, integer, number, object, array, boolean
*
* 사용:
* var r = DjbSwaggerValidator.validateOperation(operation, parsedRequest, rootSpec);
* if (!r.ok) console.log(r.errors); // [{ path, message, value }]
*/
(function (global) {
"use strict";
function resolveRef(spec, ref) {
if (ref.indexOf("#/") !== 0) return null;
var parts = ref.slice(2).split("/");
var cur = spec;
for (var i = 0; i < parts.length; i++) {
if (cur == null) return null;
cur = cur[decodeURIComponent(parts[i]).replace(/~1/g, "/").replace(/~0/g, "~")];
}
return cur || null;
}
function deref(spec, schema, seen) {
if (!schema || typeof schema !== "object") return schema;
if (schema.$ref) {
if (seen && seen.indexOf(schema.$ref) !== -1) return {};
var next = resolveRef(spec, schema.$ref);
var seen2 = (seen || []).concat([schema.$ref]);
return deref(spec, next, seen2);
}
return schema;
}
function typeMatches(value, type) {
if (value === null || value === undefined) return false;
switch (type) {
case "string": return typeof value === "string";
case "integer": return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
case "number": return typeof value === "number" && !isNaN(value);
case "boolean": return typeof value === "boolean";
case "array": return Array.isArray(value);
case "object": return typeof value === "object" && !Array.isArray(value);
default: return true;
}
}
function pushErr(errors, path, message, value) {
errors.push({ path: path || "/", message: message, value: value });
}
function checkSchema(spec, schema, value, path, errors) {
if (!schema) return;
schema = deref(spec, schema);
if (value === undefined || value === null) {
// required 는 상위 object 단계에서 확인. 여기서는 통과.
return;
}
if (schema.type && !typeMatches(value, schema.type)) {
var actual = typeof value === "object" ? (Array.isArray(value) ? "array" : "object") : typeof value;
pushErr(errors, path, '타입이 "' + schema.type + '" 이어야 합니다 (현재: ' + actual + ")", value);
return;
}
if (Array.isArray(schema.enum) && schema.enum.indexOf(value) === -1) {
pushErr(errors, path, "허용된 값이 아닙니다 (허용: " + schema.enum.join(", ") + ")", value);
}
if (schema.type === "string" || typeof value === "string") {
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
pushErr(errors, path, "최대 " + schema.maxLength + " 자까지 허용됩니다 (현재: " + value.length + "자)", value);
}
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
pushErr(errors, path, "최소 " + schema.minLength + " 자 이상이어야 합니다 (현재: " + value.length + "자)", value);
}
if (schema.pattern) {
try {
if (!new RegExp(schema.pattern).test(value)) {
pushErr(errors, path, "형식이 올바르지 않습니다 (pattern: " + schema.pattern + ")", value);
}
} catch (e) { /* invalid regex in spec — skip */ }
}
}
if (schema.type === "integer" || schema.type === "number" || typeof value === "number") {
if (typeof schema.maximum === "number" && value > schema.maximum) {
pushErr(errors, path, "최대값 " + schema.maximum + " 이하여야 합니다 (현재: " + value + ")", value);
}
if (typeof schema.minimum === "number" && value < schema.minimum) {
pushErr(errors, path, "최소값 " + schema.minimum + " 이상이어야 합니다 (현재: " + value + ")", value);
}
}
if (schema.type === "object" && schema.properties) {
var requiredList = Array.isArray(schema.required) ? schema.required : [];
requiredList.forEach(function (reqKey) {
if (value[reqKey] === undefined || value[reqKey] === null || value[reqKey] === "") {
pushErr(errors, path + "/" + reqKey, "필수 항목이 누락되었습니다", undefined);
}
});
Object.keys(schema.properties).forEach(function (key) {
if (value[key] !== undefined && value[key] !== null) {
checkSchema(spec, schema.properties[key], value[key], path + "/" + key, errors);
}
});
}
if (schema.type === "array" && schema.items && Array.isArray(value)) {
value.forEach(function (item, idx) {
checkSchema(spec, schema.items, item, path + "/" + idx, errors);
});
}
}
/**
* Swagger UI request 객체에서 header/query/body 추출해 operation 정의로 검증.
*
* @param {Object} operation spec.paths[path][method] 객체
* @param {Object} req { url, method, headers, body, ... }
* @param {Object} rootSpec $ref 해석을 위한 전체 OpenAPI 문서(json)
* @returns {{ ok:boolean, errors:Array<{path,message,value}> }}
*/
function validateOperation(operation, req, rootSpec) {
var errors = [];
if (!operation) return { ok: true, errors: errors };
// 1. parameters 검증 (header / query). path 는 Swagger 가 이미 치환 → 건너뜀.
var params = Array.isArray(operation.parameters) ? operation.parameters : [];
for (var i = 0; i < params.length; i++) {
var p = deref(rootSpec, params[i]);
var where = p.in;
var name = p.name;
var schema = p.schema ? deref(rootSpec, p.schema) : { type: "string" };
var value;
if (where === "header") {
var headers = req.headers || {};
var matchKey = Object.keys(headers).filter(function (k) {
return k.toLowerCase() === String(name).toLowerCase();
})[0];
value = matchKey ? headers[matchKey] : undefined;
} else if (where === "query") {
try {
var u = new URL(req.url, "http://_local");
value = u.searchParams.get(name);
} catch (e) { value = undefined; }
} else {
continue; // path/cookie 등은 건너뜀
}
if (p.required && (value === undefined || value === "" || value === null)) {
pushErr(errors, (where === "query" ? "query" : "header") + "[" + name + "]",
(where === "query" ? "필수 쿼리 파라미터가" : "필수 헤더가") + " 누락되었습니다", undefined);
continue;
}
if (value !== undefined && value !== null && value !== "") {
var coerced = (schema.type === "integer" || schema.type === "number") ? Number(value) : String(value);
checkSchema(rootSpec, schema, coerced, (where === "query" ? "query" : "header") + "[" + name + "]", errors);
}
}
// 2. requestBody 검증
if (operation.requestBody) {
var rb = deref(rootSpec, operation.requestBody);
if (rb.required && (req.body === undefined || req.body === null || req.body === "")) {
pushErr(errors, "body", "요청 본문이 비어 있습니다", undefined);
} else if (req.body) {
var contentTypes = Object.keys(rb.content || {});
var ctHeader = (req.headers && (req.headers["Content-Type"] || req.headers["content-type"])) || contentTypes[0];
var matchedCt = contentTypes.filter(function (c) {
return ctHeader && ctHeader.toLowerCase().indexOf(c.split(";")[0].toLowerCase()) !== -1;
})[0] || contentTypes[0];
var bodySchema = (matchedCt && rb.content[matchedCt]) ? deref(rootSpec, rb.content[matchedCt].schema) : null;
var parsed = req.body;
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); }
catch (e) {
pushErr(errors, "body", "JSON 파싱 실패: " + e.message, req.body);
return { ok: false, errors: errors };
}
}
if (bodySchema) {
checkSchema(rootSpec, bodySchema, parsed, "body", errors);
}
}
}
return { ok: errors.length === 0, errors: errors };
}
global.DjbSwaggerValidator = {
validateOperation: validateOperation,
_internal: { resolveRef: resolveRef, deref: deref, checkSchema: checkSchema }
};
})(window);
@@ -1,7 +1,7 @@
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
height: 100%;
overflow-y: scroll;
}
*,
@@ -12,4 +12,5 @@ html {
body {
margin: 0;
background: #fafafa;
}
@@ -1,79 +1,6 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Swagger UI: OAuth2 Redirect</title>
</head>
<body>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1).replace('?', '&');
} else {
qp = location.search.substring(1);
}
arr = qp.split("&");
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value);
}
) : {};
isValid = qp.state === sentState;
if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg;
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
if (document.readyState !== 'loading') {
run();
} else {
document.addEventListener('DOMContentLoaded', function () {
run();
});
}
</script>
<script src="oauth2-redirect.js"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More