31 Commits

Author SHA1 Message Date
Rinjae 171feee9ea Q&A 댓글 관련 코드 리팩토링 및 역할 기반 알림 기능 개선
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- InquiryComment 코드 구조 리팩토링 및 역할 분리
- portal-admin 알림 기능 추가 및 예외 처리 개선
- InquiryComment 관련 불필요 클래스/메서드 제거
2026-06-15 18:49:14 +09:00
Rinjae 3f664bcd5c Q&A 댓글 기능 추가 및 첨부파일 제거
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- djb 패키지 분리 - 댓글 CRUD/권한/알림 일체
- 같은 법인 공유 - 상세/댓글 조회 정책 일원화
- 작성자명 매핑 - PortalUser/TSEAIRM02 양쪽 조회
- 목록 - 댓글 수 표시 및 첨부 UI/로직 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 12:09:39 +09:00
Rinjae c77c0a2f91 ci - 워크플로 정비 및 단위테스트 안정화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Gitea Generic Package 업로드 단계 추가
- 단위테스트, e2e 워크플로 분리
- systemd 유닛 동기화 단계 제거
- checkout step 이름·주석 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 21:06:11 +09:00
Rinjae 4482f6f807 CI - systemd 기반 워크플로 보강
eapim-portal CI / build (push) Has been cancelled
- systemd 인스턴스 정지/정리 로직 추가
- 사용자 systemd unit 파일 동기화 및 리로드 처리
- Tomcat 실행 방식을 systemd로 전환
2026-06-12 18:36:00 +09:00
Rinjae 34f5a89f78 readiness probe 추가 및 워크플로 보강
- ReadinessController로 DB 연결 여부 확인 엔드포인트 추가
- CI 워크플로에 readiness probe 검증 로직 추가 (120초 제한)
- develop, stage 브랜치 push 이벤트 트리거 추가
2026-06-12 18:23:13 +09:00
Rinjae 9238584cb9 ci - 폐쇄망 액션 경로 사내 Gitea로 변경
- actions/checkout, upload-artifact를
  http://172.30.1.50:3000/actions 로 교체
- github.com 접근 불가로 빌드 실패 해결

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 17:28:48 +09:00
Rinjae 23235bb854 2-Legged 가이드 삭제 완료
- 관련 HTML 제거
- 메뉴 및 경로 수정
2026-06-12 17:11:13 +09:00
Rinjae 2d67647c8f Merge branch 'feats/ui' 2026-06-12 16:15:24 +09:00
Rinjae 4824149ba9 Merge branch 'feats/ci-test' 2026-06-12 16:12:17 +09:00
Rinjae 4927c301f3 개발가이드 페이지 추가 2026-06-10 19:29:24 +09:00
Rinjae d9830fb47d eapim-portal CI 워크플로 및 JNDI 전환
- gitea actions self-hosted runner 워크플로
- EMS/Gateway 데이터소스 JNDI 적용 및 보강
- 부팅/로깅 경로 /prod/eapim, /logs/eapim 으로 정리
2026-06-09 19:12:47 +09:00
Rinjae 6a1fade68d log - jndi-name 미설정시 warn 추가
- portal / gateway datasource 둘다 적용
- 직접 접속 모드 진입 사유 명시
2026-06-09 19:08:19 +09:00
Rinjae 357d609552 fix - jndi lookup 에 resourceRef true 적용
- java:comp/env 네임스페이스 자동 prepend
- tomcat ContextResource 매칭 위함
2026-06-09 18:58:04 +09:00
Rinjae cce9753e36 ci - 로깅 경로 /logs → /logs/eapim 수정 2026-06-09 16:38:10 +09:00
Rinjae 06fdcb8bb6 ci - 로깅 경로 통일성 확보 및 수정
- 로그 디렉토리 /Log/App → /logs/eapim 으로 변경
2026-06-09 16:36:04 +09:00
Rinjae efb88caab5 ci - tomcat 경로 /prod/eapim 으로 이동
- CATALINA_BASE / CATALINA_HOME / CATALINA_PID 갱신
2026-06-09 16:19:16 +09:00
Rinjae fb28a31da1 - EMS/Gateway 데이터소스 JNDI 설정으로 전환
- Hibernate Dialect 및 NamingStrategy 적용
- 불필요한 Proxy 및 로깅 설정 제거
2026-06-09 16:16:48 +09:00
Rinjae b38ca6fe0b - KJB-SafeDB 잔여 설정 제거
- Atomikos 경로 및 로깅 경로 수정
2026-06-09 15:48:08 +09:00
Rinjae 86336d52f0 ci - tomcat 만 jdk17 로 실행
- 빌드는 jdk8, 톰캣 startup/shutdown 만 jdk17
- JAVA_HOME_TOMCAT 환경변수로 분리
2026-06-09 15:41:50 +09:00
Rinjae 53ca4a2a26 ci - tomcat 배포 및 헬스체크 추가
- ROOT.war 로 /prod/devportal/webapps 배포
- startup.sh 후 90초 내 GET / 200 기대
- post-clean 으로 shutdown 보장
2026-06-09 15:35:39 +09:00
Rinjae 36a59b2b64 ci - secret 이름을 CI_TOKEN 으로 변경
- gitea 가 GITEA_ 접두어를 예약어로 차단
2026-06-09 12:22:41 +09:00
Rinjae cdf57c130c ci - actions/checkout 다중 호출 회피
- 두 의존 repo는 raw git clone 으로 체크아웃
- act_runner 0.6.1 액션 캐시 race 우회
2026-06-09 12:20:39 +09:00
Rinjae 7e123c088f ci - eapim-portal gitea actions 워크플로 추가
- master / feats/ci-test push 시 트리거
- 세 repo composite 체크아웃 후 빌드
- war 산출물 업로드
2026-06-09 12:00:28 +09:00
Rinjae daafbb596a ApiTesterFilter 개선 및 KJBank 흔적 제거
- "/api/call-api" 경로 필터링 로직 추가
- KJBank 관련 문자열 DJBank로 변경
2026-06-04 11:03:41 +09:00
Rinjae 0ffee7a101 폰트 기본 설정에 '원신한' 추가 및 SCSS/CSS 파일 업데이트 2026-06-02 15:00:06 +09:00
Rinjae b47f76a783 Merge commit '157c66ca9d4eda83720f3ae35a3e3585408326ab' 2026-06-02 09:34:53 +09:00
Rinjae f591c5c81d KJBank 흔적 삭제 2026-06-02 09:28:44 +09:00
Rinjae a0ea62f8cc KJBank 흔적 삭제 2026-06-02 09:27:16 +09:00
eastargh 157c66ca9d DB스키마 변경 AGWADM->AGWAPP, EMSADM->EMSAPP 2026-05-28 10:57:50 +09:00
Rinjae ba5b2e61a5 - logback 관련 XML 설정 파일 삭제
- KJBank 레이아웃, 프래그먼트 파일 DJBank로 변경
- HTML 레이아웃 파일 KJBank에서 DJBank로 업데이트
2026-05-24 22:21:57 +09:00
Rinjae 5deda2a004 - K8sUtil 파일 삭제
- 승인관리 완전삭제 기능 추가
- DJBank로 명칭 업데이트
2026-05-21 09:52:34 +09:00
178 changed files with 4894 additions and 4476 deletions
+180
View File
@@ -0,0 +1,180 @@
name: eapim-portal CI
on:
push:
branches:
- master
- develop
- stage
- feats/ci-test
jobs:
build:
runs-on: [self-hosted, linux, eapim-portal]
env:
JAVA_HOME: /apps/opts/jdk8
JAVA_HOME_TOMCAT: /apps/opts/jdk17
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"
defaults:
run:
working-directory: eapim-portal
steps:
- name: Checkout eapim-portal (this branch)
# ref 미지정 — 트리거된 브랜치/commit(github.sha) 그대로 빌드.
uses: http://172.30.1.50:3000/actions/checkout@v4
with:
path: eapim-portal
- name: Checkout elink-portal-common (master, dependency)
# 의존 모듈은 항상 master 고정.
working-directory: ${{ github.workspace }}
run: |
rm -rf elink-portal-common
git clone --depth=1 --branch master \
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-portal-common.git \
elink-portal-common
- name: Checkout elink-online-core-jpa (master, dependency)
# 의존 모듈은 항상 master 고정.
working-directory: ${{ github.workspace }}
run: |
mkdir -p eapim-online
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
- name: Verify toolchain
run: |
java -version
gradle --version
- name: Gradle build (no tests)
run: gradle clean build -x test --no-daemon
- name: Upload WAR artifacts
uses: http://172.30.1.50:3000/actions/upload-artifact@v3
with:
name: eapim-portal-wars-${{ github.sha }}
path: |
eapim-portal/build/libs/eapim-portal.war
eapim-portal/build/libs/eapim-portal-boot.war
if-no-files-found: error
- name: Publish WAR to Gitea Generic Package
working-directory: ${{ github.workspace }}
env:
GITEA_URL: http://172.30.1.50:3000
PKG_OWNER: djb-eapim
PKG_NAME: eapim-portal
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
VERSION="$(TZ=Asia/Seoul date +%Y-%m-%d-%H%M)-${SHORT_SHA}"
BASE="$GITEA_URL/api/packages/$PKG_OWNER/generic/$PKG_NAME/$VERSION"
echo "package version: $VERSION"
echo "endpoint base : $BASE"
for WAR in eapim-portal.war eapim-portal-boot.war; do
SRC="eapim-portal/build/libs/$WAR"
if [ ! -f "$SRC" ]; then
echo "::error::missing artifact: $SRC"
exit 1
fi
HTTP=$(curl -sS -o "/tmp/pkg-upload-$WAR.log" -w '%{http_code}' \
--user "oauth2:${{ secrets.CI_TOKEN }}" \
--upload-file "$SRC" \
"$BASE/$WAR")
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "::error::upload failed for $WAR (HTTP $HTTP)"
cat "/tmp/pkg-upload-$WAR.log" || true
exit 1
fi
echo " uploaded: $WAR (HTTP $HTTP)"
done
echo "published: $GITEA_URL/$PKG_OWNER/-/packages/generic/$PKG_NAME/$VERSION"
- name: Stop tomcat (pre-clean)
working-directory: ${{ github.workspace }}
run: |
# 1) systemd 관리 인스턴스 정지 (이번 워크플로가 띄운 것)
systemctl --user stop eapim-portal 2>/dev/null || true
# 2) systemd 외부 detached 인스턴스 (eapim-boot-start.sh 등) 정리
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"
- name: Deploy war as ROOT
working-directory: ${{ github.workspace }}
run: |
DEPLOY_DIR="$CATALINA_BASE/webapps"
rm -rf "$DEPLOY_DIR/ROOT" "$DEPLOY_DIR/ROOT.war"
cp eapim-portal/build/libs/eapim-portal.war "$DEPLOY_DIR/ROOT.war"
ls -la "$DEPLOY_DIR"
- name: Start tomcat and readiness probe (timeout 120s)
working-directory: ${{ github.workspace }}
run: |
LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
BEFORE=0
[ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
# systemd 가 Tomcat 을 새 cgroup 으로 띄움 — runner step 종료와 무관하게 살아남음
systemctl --user start eapim-portal
DEADLINE=$(($(date +%s) + 120))
LIVE_OK=0
STATUS=000
BODY=""
while [ $(date +%s) -lt $DEADLINE ]; do
# Stage 1: liveness — servlet 응답 가능?
if [ "$LIVE_OK" = "0" ]; then
LIVE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
http://localhost:$DEPLOY_HTTP_PORT/health 2>/dev/null || echo "000")
if [ "$LIVE" = "200" ]; then
echo "Liveness OK (/health)"
LIVE_OK=1
fi
fi
# Stage 2: readiness — DB(EMS, Gateway)까지 검증
if [ "$LIVE_OK" = "1" ]; then
BODY=$(curl -sS -o /tmp/ready-body.json -w '%{http_code}' --max-time 3 \
http://localhost:$DEPLOY_HTTP_PORT/health/ready 2>/dev/null || echo "000")
STATUS=$BODY
if [ "$STATUS" = "200" ]; then
echo "Readiness OK (/health/ready)"
cat /tmp/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 "::error::Startup error detected in log"
tail -c +$((BEFORE+1)) "$LOG" | tail -80
exit 1
fi
sleep 2
done
if [ "$STATUS" != "200" ]; then
echo "::error::Readiness probe failed within 120s (last HTTP status: $STATUS)"
[ -f /tmp/ready-body.json ] && echo "--- last readiness body ---" && cat /tmp/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
+60
View File
@@ -0,0 +1,60 @@
name: eapim-portal E2E Test
# 수동 트리거 전용. e2e 태그가 붙은 Selenium/통합 테스트만 실행.
# 요구 인프라:
# - Chrome + libgbm/libxkbcommon-x11 (Selenium 4종)
# - Oracle JDBC 또는 testcontainers (SignupFileTest)
# 미설치 환경에서는 SessionNotCreated/DB 에러로 일부 케이스가 실패할 수 있음.
on:
workflow_dispatch:
jobs:
e2e:
runs-on: [self-hosted, linux, eapim-portal]
env:
JAVA_HOME: /apps/opts/jdk8
defaults:
run:
working-directory: eapim-portal
steps:
- name: Checkout eapim-portal (this branch)
uses: http://172.30.1.50:3000/actions/checkout@v4
with:
path: eapim-portal
- name: Checkout elink-portal-common (master, dependency)
working-directory: ${{ github.workspace }}
run: |
rm -rf elink-portal-common
git clone --depth=1 --branch master \
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-portal-common.git \
elink-portal-common
- name: Checkout elink-online-core-jpa (master, dependency)
working-directory: ${{ github.workspace }}
run: |
mkdir -p eapim-online
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
- name: Verify toolchain
run: |
java -version
gradle --version
- name: Gradle e2e test
run: gradle test --no-daemon -PincludeE2E=true
- name: Upload test results
if: always()
uses: http://172.30.1.50:3000/actions/upload-artifact@v3
with:
name: eapim-portal-e2e-${{ github.sha }}
path: |
eapim-portal/build/reports/tests/test/
eapim-portal/build/test-results/test/
if-no-files-found: warn
+63
View File
@@ -0,0 +1,63 @@
name: eapim-portal Test
on:
push:
branches:
- master
- develop
- stage
- feats/ci-test
jobs:
test:
runs-on: [self-hosted, linux, eapim-portal]
env:
JAVA_HOME: /apps/opts/jdk8
defaults:
run:
working-directory: eapim-portal
steps:
- name: Checkout eapim-portal (this branch)
# ref 미지정 — 트리거된 브랜치/commit(github.sha) 그대로 빌드.
uses: http://172.30.1.50:3000/actions/checkout@v4
with:
path: eapim-portal
- name: Checkout elink-portal-common (master, dependency)
# 의존 모듈은 항상 master 고정.
working-directory: ${{ github.workspace }}
run: |
rm -rf elink-portal-common
git clone --depth=1 --branch master \
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-portal-common.git \
elink-portal-common
- name: Checkout elink-online-core-jpa (master, dependency)
# 의존 모듈은 항상 master 고정.
working-directory: ${{ github.workspace }}
run: |
mkdir -p eapim-online
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
- name: Verify toolchain
run: |
java -version
gradle --version
- name: Gradle test
run: gradle test --no-daemon
- name: Upload test results
# 실패해도 결과 리포트는 올려서 원인 분석 가능하게.
if: always()
uses: http://172.30.1.50:3000/actions/upload-artifact@v3
with:
name: eapim-portal-test-${{ github.sha }}
path: |
eapim-portal/build/reports/tests/test/
eapim-portal/build/test-results/test/
if-no-files-found: warn
+39 -4
View File
@@ -8,7 +8,7 @@
**기술 스택:**
- Spring Boot 2.7.18 with Spring MVC and Thymeleaf
- Java 8 (targetCompatibility: 1.8)
- Java 8 (sourceCompatibility/targetCompatibility: 1.8)
- Gradle 8.7 빌드 시스템
- Oracle 19c 데이터베이스 (JNDI를 통한 이중 데이터소스)
- Spring Security (커스텀 인증)
@@ -59,7 +59,7 @@ git push origin jenkins_with_weblogic
### 애플리케이션 실행
- gradlew 사용 금지 - offline gradle 단독 실행
- msi-gf63 컴퓨터에서는 /c/eactive/workspaces/shell-scripts/kjb-gradle.sh 로 gradle 실행 (jdk, gradle 등 환경 변수 세팅 후 gradle 실행하는 스크립트)
- JDK/Gradle 환경 변수는 저장소 루트의 `.envrc`(direnv)로 자동 설정됨 (JAVA_HOME → Zulu JDK 8, GRADLE_USER_HOME → 격리 디렉터리)
```bash
# dev 프로파일로 실행 (기본값)
@@ -173,13 +173,13 @@ com.eactive.apim.portal/
1. **EMS Database** (Portal/Admin)
- JNDI: `jdbc/dsOBP_EMS`
- Schema: `EMSADM`
- Schema: `EMSAPP`
- Entities: `com.eactive.apim.portal.apps.*`
- 목적: 포털 사용자, 조직, API 키, 승인
2. **Gateway Database** (API Specs)
- JNDI: `jdbc/dsOBP_AGW`
- Schema: `AGWADM`
- Schema: `AGWAPP`
- Entities: `com.eactive.eai.data.entity.onl.*`
- 목적: API 명세, 서비스, 메시지
@@ -265,6 +265,41 @@ API 키는 승인 워크플로우와 함께 관리됩니다:
8. `src/main/resources/templates/views/newfeature/`에 Thymeleaf 템플릿 추가
9. `application.yml``pages` 섹션에 라우트 설정 (또는 `@GetMapping`/`@PostMapping`으로 직접 매핑)
### 메뉴/네비게이션 항목 추가하기
**중요:** 메뉴 정의가 **두 곳으로 분리**되어 있다. yml 한 곳만 고치면 헤더 드롭다운에 안 보이고, 헤더만 고치면 브레드크럼이 비어 보인다. 메뉴 추가/이름 변경/순서 변경 시 **반드시 두 파일을 같이 수정**한다.
1. **`application.yml``page:` 트리** (브레드크럼·페이지 메타용)
- `PageService`가 URL → breadcrumb 매핑에 사용
- 부모 메뉴(`service`, `apis`, `community` 등) 아래 `children:`에 새 키를 추가
```yaml
page:
home:
children:
service:
children:
oauth2_guide:
name: "OAuth2 인증가이드"
path: "/service/oauth2-guide"
```
2. **`templates/views/fragment/djbank/header_container.html`** (상단 글로벌 네비)
- `<nav class="header-center"><ul class="nav-menu">` 블록에 정적 `<li>`로 하드코딩되어 있다 — yml 트리를 iterate 하지 **않음**
- 부모 메뉴의 `<ul class="sub-menu">`에 `<li><a th:href="@{/...}">메뉴명</a></li>` 직접 추가
3. **라우트도 같이 등록** (`application.yml`의 `portal.pages` 매핑 섹션, 또는 `@GetMapping`)
```yaml
portal:
pages:
- path-pattern: /service/oauth2-guide
method: GET
view-name: apps/service/oauth2-guide
```
4. **검증**: 변경 후 (a) 헤더 드롭다운에 항목이 보이는지, (b) 새 페이지 진입 시 브레드크럼에 메뉴명이 보이는지 둘 다 확인.
5. **재시작**: `application.yml` 변경은 `PortalPageHandlerMapper`가 `@PostConstruct`에서 한 번만 등록하므로 **자동 라이브 리로드 안 됨** — 수동 재시작 필요. HTML/CSS만 라이브 반영됨.
### MapStruct DTO 매핑
타입 안전한 DTO 변환을 위해 MapStruct 사용:
+15 -55
View File
@@ -69,10 +69,17 @@ eapim-portal
### 사전 요구사항
- JDK 8 이상
- Gradle 8.7
아래 도구 설치 및 검증 절차는 별도 문서로 분리되어 있습니다. 신규 개발자는 먼저 이 문서를 끝까지 진행하세요.
📄 **[개발환경 준비 사항](djb-docs/개발환경-준비-사항.md)** — JDK 8 · Gradle 8.7 · Git · Oracle 19c · Node.js & SASS · direnv · IDE 설정
요약:
- JDK 8 (Zulu 권장)
- Gradle 8.7 (gradlew 사용 금지)
- Git
- Oracle 19c (또는 개발 환경에 따라 접근 가능한 DB)
- Oracle 19c 접근 (개발: `192.168.240.177:1599`)
- Node.js 18 LTS 이상 (SCSS 컴파일용)
### Git Bash에서 초기 세팅
@@ -90,10 +97,7 @@ export GIT_REPO_BASE="ssh://git@192.168.240.178:18081/eapim"
# export GIT_REPO_BASE="https://git.eactive.synology.me:8090/kjb-eapim"
# Gradle 명령어 설정
# 일반 환경: gradle
# msi-gf63 환경: /c/eactive/workspaces/shell-scripts/kjb-gradle.sh
export GRADLE_CMD="gradle"
# export GRADLE_CMD="/c/eactive/workspaces/shell-scripts/kjb-gradle.sh"
# ====================================
# 2. 작업 디렉토리 생성
@@ -128,12 +132,6 @@ git clone ${GIT_REPO_BASE}/elink-portal-common.git
cd elink-portal-common
git checkout master # 또는 필요한 브랜치
# kjb-safedb 클론
cd $WORKSPACE_DIR
git clone ${GIT_REPO_BASE}/kjb-safedb.git
cd kjb-safedb
git checkout master # 또는 필요한 브랜치
# ====================================
# 6. 최종 폴더 구조 확인
# ====================================
@@ -142,7 +140,6 @@ git checkout master # 또는 필요한 브랜치
# ├── eapim-online/
# │ └── elink-online-core-jpa/
# ├── elink-portal-common/
# └── kjb-safedb/
# ====================================
# 7. eapim-portal 프로젝트로 이동 및 디펜던시 다운로드
@@ -169,10 +166,7 @@ set GIT_REPO_BASE=ssh://git@192.168.240.178:18081/eapim
REM set GIT_REPO_BASE=https://git.eactive.synology.me:8090/kjb-eapim
REM Gradle 명령어 설정
REM 일반 환경: gradle
REM msi-gf63 환경: C:\eactive\workspaces\shell-scripts\kjb-gradle.sh
set GRADLE_CMD=gradle
REM set GRADLE_CMD=C:\eactive\workspaces\shell-scripts\kjb-gradle.sh
REM ====================================
REM 2. 작업 디렉토리 생성
@@ -207,12 +201,6 @@ git clone %GIT_REPO_BASE%/elink-portal-common.git
cd elink-portal-common
git checkout master
REM kjb-safedb 클론
cd /d "%WORKSPACE_DIR%"
git clone %GIT_REPO_BASE%/kjb-safedb.git
cd kjb-safedb
git checkout master
REM ====================================
REM 6. 최종 폴더 구조 확인
REM ====================================
@@ -221,7 +209,6 @@ REM ├── eapim-portal\
REM ├── eapim-online\
REM │ └── elink-online-core-jpa\
REM ├── elink-portal-common\
REM └── kjb-safedb\
REM ====================================
REM 7. eapim-portal 프로젝트로 이동 및 디펜던시 다운로드
@@ -253,30 +240,19 @@ REM Gradle 디펜던시 다운로드
- **gradlew 사용 금지**: 반드시 시스템에 설치된 gradle을 직접 사용하거나 환경에 맞는 스크립트를 사용하세요
- **Gradle 명령어 변수화**: 위의 `GRADLE_CMD` 변수를 설정하면 환경별로 다른 gradle 명령어 사용 가능
- **SafeDB**: 암호화 기능이 필요한 경우 kjb-safedb 모듈 설정 확인
## 빌드 및 실행
### Gradle 명령어 설정
사용 환경에 따라 적절한 gradle 명령어를 설정하세요:
**Git Bash:**
```bash
# 일반 환경
export GRADLE_CMD="gradle"
# msi-gf63 환경
export GRADLE_CMD="/c/eactive/workspaces/shell-scripts/kjb-gradle.sh"
```
**Windows CMD:**
```cmd
REM 일반 환경
set GRADLE_CMD=gradle
REM msi-gf63 환경
set GRADLE_CMD=C:\eactive\workspaces\shell-scripts\kjb-gradle.sh
```
### 빌드 및 실행 명령어
@@ -310,7 +286,6 @@ kjb-eapim/
├── eapim-online/ # Online 프로젝트
│ └── elink-online-core-jpa/ # Gateway 데이터 모델
├── elink-portal-common/ # 공통 유틸리티
└── kjb-safedb/ # SafeDB 암호화 라이브러리
```
### eapim-portal 내부 구조
@@ -327,7 +302,6 @@ src/main/java/com/eactive/apim/portal/
이 프로젝트는 다음 모듈에 의존합니다 (settings.gradle 참조):
- `eapim-online/elink-online-core-jpa`: Gateway 데이터 모델 (../eapim-online/elink-online-core-jpa)
- `elink-portal-common`: 공통 유틸리티 (../elink-portal-common)
- `kjb-safedb`: SafeDB 암호화 라이브러리 (../kjb-safedb)
## SASS / CSS 스타일 개발
@@ -339,8 +313,11 @@ src/main/java/com/eactive/apim/portal/
### 설치
Node.js 및 SASS 설치 절차는 [개발환경 준비 사항 § 6. Node.js & SASS 빌드 환경](djb-docs/개발환경-준비-사항.md#6-nodejs--sass-빌드-환경) 을 참고하세요.
Node.js가 이미 설치되어 있다면 프로젝트 의존성만 설치하면 됩니다:
```bash
# Node.js가 설치된 상태에서
npm install
```
@@ -532,8 +509,6 @@ ls -lh src/main/resources/static/css/main.min.css # minified
- 사용자 관리 (내부/기업)
- API 키 관리 및 승인 워크플로우
- API 카탈로그 및 문서화
- API 테스트 프록시
- 감사 추적 (Hibernate Envers)
## 배포
@@ -543,24 +518,9 @@ ls -lh src/main/resources/static/css/main.min.css # minified
- WebLogic
- Tomcat (내장)
### Docker
```bash
./build_docker.sh
docker run -p 8080:8080 eapim-portal:latest
```
## 관련 프로젝트
프로젝트 구조 (kjb-eapim 디렉토리 기준):
- `eapim-portal/` - Portal 웹 애플리케이션 (현재 프로젝트)
- `eapim-online/` - Online 프로젝트 (elink-online-core-jpa 포함)
- `elink-portal-common/` - 공통 유틸리티 라이브러리
- `kjb-safedb/` - SafeDB 암호화 라이브러리
- `eapim-admin/` - Admin 포털 (선택사항)
- `kjb-eapim-sql/` - SQL 스크립트 (선택사항)
## 문서
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
+10 -18
View File
@@ -128,11 +128,13 @@ bootRun {
}
// annotation processor 출력 디렉토리는 IntelliJ/Gradle 통합이 자동 등록함.
// 명시적으로 srcDir 추가하면 IntelliJ idea 모드에서 MapStruct가 같은 파일을
// 중복 생성하려다 javax.annotation.processing.FilerException 발생함.
sourceSets {
main {
java {
srcDir 'src/main/java'
srcDir 'build/generated/sources/annotationProcessor/java/main'
}
}
}
@@ -156,7 +158,13 @@ processResources {
}
test {
useJUnitPlatform()
useJUnitPlatform {
if (project.hasProperty('includeE2E') && project.includeE2E.toBoolean()) {
includeTags 'e2e'
} else {
excludeTags 'e2e'
}
}
enabled = true
}
@@ -181,20 +189,4 @@ task printSourceSets {
println " Output dir : ${srcSet.output.classesDirs.asPath}"
}
}
}
// CryptoCli 실행 task (bcrypt 지원)
task cryptoCli(type: JavaExec) {
mainClass = 'com.eactive.ext.kjb.safedb.CryptoCli'
classpath = sourceSets.main.runtimeClasspath
standardInput = System.in
// SafeDB 라이브러리 경로 추가 (리눅스)
classpath += files("/safedb/JavaAPI/config")
classpath += fileTree(dir: "/safedb/JavaAPI/lib", include: ["*.jar"])
// CLI 인자 전달: ./gradlew cryptoCli --args="bcrypt hash password123"
if (project.hasProperty('args')) {
args project.args.split('\\s+')
}
}
+273
View File
@@ -0,0 +1,273 @@
# 개발환경 준비 사항
EAPIM Portal 개발에 필요한 도구와 런타임을 설치하고 검증하는 절차입니다.
처음 환경을 세팅하는 신규 개발자는 이 문서를 끝까지 따라 진행한 뒤 [README.md](../README.md)의 "초기 세팅 가이드" 단계를 이어서 수행하세요.
---
## 0. 한눈에 보는 요구사항
| 구분 | 도구 | 권장 버전 | 비고 |
|------|------|-----------|----------------------------------|
| 백엔드 | JDK | 1.8 (Zulu 8 권장) | `sourceCompatibility=1.8` |
| 빌드 | Gradle | 8.7 | gradlew 사용 금지 — 시스템 gradle 직접 사용 |
| 형상관리 | Git | 2.30 이상 | SSH 키 등록 권장 |
| DB | Oracle Client/접근 | 19c | 개발은 `192.168.240.177:1598` |
| 프론트엔드 빌드 | Node.js | 18 LTS 이상 | SCSS 컴파일 전용 |
| 프론트엔드 빌드 | npm | Node.js 함께 설치 | `sass` 패키지 사용 |
| (선택) 환경 변수 | direnv | 최신 | `.envrc` 자동 로드 |
| (선택) IDE | IntelliJ IDEA | 2023.x 이상 | Lombok / MapStruct 플러그인 |
---
## 1. JDK 8 설치
Java 8 (1.8) 호환이 필요합니다. Azul Zulu JDK 8 사용을 권장합니다.
### macOS (Homebrew)
```bash
brew install --cask zulu@8
# 설치 후 경로 확인
/usr/libexec/java_home -v 1.8
```
### Windows
1. [Azul Zulu JDK 8 다운로드](https://www.azul.com/downloads/?version=java-8-lts&package=jdk)
2. MSI 설치 후 `JAVA_HOME` 환경변수 등록
3. `PATH``%JAVA_HOME%\bin` 추가
### 검증
```bash
java -version # 1.8.x
javac -version # 1.8.x
echo $JAVA_HOME # macOS/Linux
echo %JAVA_HOME% # Windows
```
---
## 2. Gradle 8.7 설치
이 프로젝트는 **gradlew 사용을 금지**합니다. 시스템에 설치된 gradle 8.7을 직접 사용합니다.
### macOS (Homebrew + SDKMAN 권장)
```bash
# SDKMAN 설치 (이미 있으면 생략)
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
# Gradle 8.7 설치
sdk install gradle 8.7
sdk default gradle 8.7
```
### Windows
1. [Gradle 8.7 Binary-only 다운로드](https://gradle.org/releases/)
2. `C:\gradle\gradle-8.7` 경로에 압축 해제
3. `GRADLE_HOME=C:\gradle\gradle-8.7` 환경변수 등록
4. `PATH``%GRADLE_HOME%\bin` 추가
### 검증
```bash
gradle -v
# Gradle 8.7
# JVM: 1.8.x
```
---
## 3. (권장) direnv 사용
저장소 루트의 `.envrc``JAVA_HOME`, `GRADLE_USER_HOME` 등을 자동 주입합니다.
디렉터리 진입만으로 환경이 격리되므로 다른 프로젝트와 JDK가 섞이지 않습니다.
### 설치
```bash
# macOS
brew install direnv
# Windows (scoop)
scoop install direnv
```
### 셸 훅 등록 (한 번만)
```bash
# zsh
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc
# bash
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
```
### 프로젝트에서 허용
```bash
cd /path/to/eapim-portal
direnv allow
```
---
## 4. Git 설치
### macOS
```bash
brew install git
```
### Windows
[Git for Windows](https://git-scm.com/download/win) 설치. Git Bash가 함께 설치되어 README의 Bash 스니펫을 그대로 사용할 수 있습니다.
### SSH 키 등록 (사내 Git 서버용)
```bash
ssh-keygen -t ed25519 -C "your.email@djbank.co.kr"
cat ~/.ssh/id_ed25519.pub
# 출력된 공개키를 사내 Git 서버(192.168.240.178:18081)에 등록
```
### 검증
```bash
git --version
ssh -T git@192.168.240.178 -p 18081
```
---
## 5. Oracle 19c 접근 정보
개발 환경에서는 별도 Oracle 설치 없이 사내 개발 DB에 접속합니다.
- **Host**: `192.168.240.177`
- **Port**: `1599`
- **Schemas**: `EMSADM` (Portal), `AGWADM` (Gateway)
- 상세 계정/패스워드는 사내 비밀번호 관리자에게 문의
스테이징/운영은 JNDI를 통해 접근합니다 — `jdbc/dsOBP_EMS`, `jdbc/dsOBP_AGW`.
DB 클라이언트 도구는 자유 선택 (DBeaver, DataGrip, SQL Developer 등).
---
## 6. Node.js & SASS 빌드 환경
SCSS 파일을 CSS로 컴파일하기 위해 Node.js 환경이 필요합니다.
`src/main/resources/static/css/main.css` 는 SASS 컴파일 결과물이므로 **반드시 SCSS 파일을 수정한 뒤 컴파일**해야 합니다.
### 6.1 Node.js 설치 (LTS 18 이상)
#### macOS (Homebrew)
```bash
brew install node
```
#### macOS / Linux (nvm 권장 — 버전 관리 용이)
```bash
# nvm 설치
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
exec $SHELL -l
# Node.js LTS 설치
nvm install --lts
nvm use --lts
```
#### Windows
1. [Node.js LTS 다운로드](https://nodejs.org/en/download) 후 MSI 설치
2. 또는 [nvm-windows](https://github.com/coreybutler/nvm-windows/releases) 사용
#### 검증
```bash
node -v # v18.x 이상
npm -v # 9.x 이상
```
### 6.2 프로젝트 SASS 의존성 설치
```bash
cd /path/to/eapim-portal
npm install
```
`package.json``devDependencies.sass` (Dart Sass) 가 `node_modules/` 에 설치됩니다.
### 6.3 SASS 빌드 명령어
```bash
# 1회 컴파일 (개발용, 들여쓰기 유지)
npm run sass:build
# 1회 컴파일 (배포용, minified)
npm run sass:build:minified
# 파일 변경 감지 후 자동 컴파일 (개발 중 권장)
npm run sass:watch
# 빌드 + minified 동시 생성
npm run build
```
### 6.4 SASS 컴파일 검증
```bash
ls -lh src/main/resources/static/css/main.css
ls -lh src/main/resources/static/css/main.min.css
```
파일이 갱신되었고 브라우저에서 스타일이 정상 적용되면 성공입니다.
### 6.5 SASS 작업 시 주의사항
- `src/main/resources/static/css/main.css` **직접 수정 금지** — 재컴파일 시 덮어씌워짐
- SCSS 파일은 `src/main/resources/static/sass/` 하위에서만 작성
- 새 파일 추가 시 파일명 앞에 언더스코어(`_`) 필수 (`_filename.scss`)
- `@import` 대신 `@use` 사용 (Dart Sass 3.0 호환)
---
## 7. (선택) IDE 설정
### IntelliJ IDEA
1. **Lombok 플러그인** 설치 → `Settings → Plugins → Lombok`
2. **Annotation Processing 활성화**`Settings → Build → Compiler → Annotation Processors → Enable annotation processing`
3. **Gradle JVM** 설정 → `Settings → Build → Build Tools → Gradle → Gradle JVM` 을 JDK 1.8 (Zulu) 로 지정
4. **MapStruct** 는 별도 플러그인 불필요 (annotation processor 만 활성화되어 있으면 됨)
### VS Code
- `Extension Pack for Java`
- `Lombok Annotations Support`
- `Sass` (Syler) — SCSS 문법 하이라이팅
---
## 8. 최종 체크리스트
아래 명령이 모두 정상 동작하면 개발환경 준비가 완료된 것입니다.
```bash
java -version # 1.8.x
gradle -v # 8.7 / JVM 1.8
git --version
node -v # v18 이상
npm -v
direnv version # (선택)
```
이후 [README.md](../README.md) 의 "초기 세팅 가이드" 로 이동해 프로젝트 클론과 의존 모듈 설치를 진행하세요.
-91
View File
@@ -1,91 +0,0 @@
pipeline {
// 25.10.01 : 기본 브랜치 staging -> jenkins_with_weblogic 로 변경
agent { label 'apipod01' }
triggers {
pollSCM('H/5 * * * *')
}
environment {
// Java 환경 설정
JAVA_HOME = '/App/jenkins/jdks/jdk1.8.0_441'
PATH = "${JAVA_HOME}/bin:${env.PATH}"
// Gradle 환경 설정
GRADLE_HOME = '/App/jenkins/gradle-8.7'
GRADLE_USER_HOME = '/App/jenkins/gradle-home'
// GRADLE_OPTS = '--offline'
// Git 저장소 정보
GIT_SERVER = '192.168.240.178:18081'
GIT_CREDENTIALS_ID = 'git-ssh-credentials'
// 프로젝트 디렉토리 구조
PROJECT_HOME = '/Data/jenkins/eapim'
BRANCH = 'jenkins_with_weblogic'
}
stages {
stage('Git pull') {
steps {
echo "[portal] Git pull from branch: ${BRANCH}"
dir("${PROJECT_HOME}/eapim-portal") {
git url: "ssh://git@${GIT_SERVER}/eapim/eapim-portal", branch: "${BRANCH}", credentialsId: "${GIT_CREDENTIALS_ID}"
}
echo "[online-core-jpa] Git pull from branch: ${BRANCH}"
dir("${PROJECT_HOME}/eapim-online/elink-online-core-jpa") {
git url: "ssh://git@${GIT_SERVER}/eapim/elink-online-core-jpa", branch: "${BRANCH}", credentialsId: "${GIT_CREDENTIALS_ID}"
}
echo "[portal-common] Git pull from branch: ${BRANCH}"
dir("${PROJECT_HOME}/elink-portal-common") {
git url: "ssh://git@${GIT_SERVER}/eapim/elink-portal-common", branch: "${BRANCH}", credentialsId: "${GIT_CREDENTIALS_ID}"
}
echo "[kjb-safedb] Git pull from branch: ${BRANCH}"
dir("${PROJECT_HOME}/kjb-safedb") {
git url: "ssh://git@${GIT_SERVER}/eapim/kjb-safedb", branch: "${BRANCH}", credentialsId: "${GIT_CREDENTIALS_ID}"
}
}
}
stage('Build') {
steps {
tool name: 'Oracle-JDK-1.8', type: 'hudson.model.JDK'
// tool name: 'Gradle-8.7', type: 'hudson.tasks.Gradle'
// echo "[online-core-jpa] Build project"
// dir("${PROJECT_HOME}/elink-online-multi/elink-online-core-jpa") {
// sh '${GRADLE_HOME}/bin/gradle clean build -x test'
// }
// echo "[portal-common] Build project"
// dir("${PROJECT_HOME}/elink-portal-common") {
// sh '${GRADLE_HOME}/bin/gradle clean build -x test'
// }
echo "[portal] Build project"
dir("${PROJECT_HOME}/eapim-portal") {
sh '${GRADLE_HOME}/bin/gradle clean build -x test'
}
}
}
stage('Archive WAR') {
steps {
echo "[portal] Archive WAR file"
dir("${PROJECT_HOME}/eapim-portal/build/libs") {
archiveArtifacts artifacts: '*.war', fingerprint: true
}
}
}
}
}
-25
View File
@@ -1,25 +0,0 @@
# 광주은행 eAPIM
## 커스텀 항목
### 포탈 Property (EMS 프로퍼티)
- `disable_features.user_email_verify' == true
이메일 인증 기능 비활성화(회원 가입시 이메일 인증은 한걸로 처리)
### 디비 접속 방법 Direct -> JNDI 로 변경
- 은행 내부 환경에선 모두 JNDI 사용하기 때문에 서버환경에서 동작하는 profile 내 application.yaml 에서 디비 접속 정보 삭제
## 서버 운영 중 긴급 로깅이 필요할 경우 JVM 파라미터 추가 하여 재기동
- DEBUG 레벨 root 로그 / debug.log 로 파일 작성
```
-Dlogging.config=classpath:logback-debug.xml
```
## 참고자료
### 개발용 파라미터
```
-Dlogging.config=classpath:logback-rinjae.xml
-Dinst.Name=devSvr98
-Duser.language=en
-Duser.country=US
-Dkjb_safedb.mode=fake
```
-113
View File
@@ -1,113 +0,0 @@
# 사업자등록번호 중복 체크 구현 작업 계획서
## 1. 현황 분석
### 문제점
- 법인 등록 시 사업자등록번호(compRegNo) 중복 검증이 없음
- `PortalOrgRepository.findByCompRegNo()` 메서드는 존재하나 사용되지 않음
- 동일한 사업자등록번호로 여러 법인이 등록될 수 있는 상태
### 검증 시점
1. **실시간 검증**: 사용자가 사업자등록번호 입력 시 `/check-business-number` API 호출
2. **등록 시 검증**: 실제 법인 생성 시점 (`registerOrgFromDTOWithFile`)
---
## 2. 수정 대상 파일
| 파일 경로 | 수정 내용 |
|-----------|-----------|
| `src/main/java/.../apps/user/service/PortalOrgService.java` | 사업자등록번호 중복 체크 메서드 추가 |
| `src/main/java/.../apps/user/facade/OrgRegisterFacadeImpl.java` | `checkBusinessNumber()` 중복 체크 로직 추가, 등록 전 중복 체크 추가 |
---
## 3. 구현 상세
### 3.1 PortalOrgService.java
```java
// 추가할 메서드
public boolean existsByCompRegNo(String compRegNo) {
return portalOrgRepository.findByCompRegNo(compRegNo).isPresent();
}
```
### 3.2 OrgRegisterFacadeImpl.java
#### checkBusinessNumber 메서드 수정
- 형식 검증 + 중복 검증 동시 수행
- 중복 시 "이미 등록된 사업자등록번호입니다." 메시지 반환
```java
@Override
public ResponseEntity<ValidationResponse> checkBusinessNumber(String compRegNo) {
// 1. 형식 검증
boolean isValidFormat = validationService.isValidBusinessNumber(compRegNo);
if (!isValidFormat) {
return ResponseEntity.ok(new ValidationResponse(false, "유효하지 않은 사업자 등록번호입니다."));
}
// 2. 중복 검증
boolean isDuplicate = portalOrgService.existsByCompRegNo(compRegNo);
if (isDuplicate) {
return ResponseEntity.ok(new ValidationResponse(false, "이미 등록된 사업자등록번호입니다."));
}
return ResponseEntity.ok(new ValidationResponse(true, "사용 가능한 사업자 등록번호입니다."));
}
```
#### 법인 생성 메서드들에 중복 체크 추가
- `registerNewCorporateUser()`
- `convertExistingUserToCorporate()`
- `registerNewCorporateUserWithExistingData()`
각 메서드에서 `portalOrgService.registerOrgFromDTOWithFile()` 호출 전 중복 체크 수행
---
## 4. 검증 흐름
```
[사용자 입력]
[실시간 검증] /check-business-number API
├── 형식 검증 (10자리 숫자)
└── 중복 검증 (DB 조회)
[등록 요청]
[등록 시 검증] registerOrgFromDTOWithFile 호출 전
└── 중복 체크 (이중 방어)
[법인 생성]
```
---
## 5. 기존 중복 데이터 처리
- 기존에 중복 등록된 데이터는 그대로 유지
- 신규 등록 시에만 중복 체크 적용
- 필요시 관리자 화면에서 중복 데이터 조회/정리 기능 별도 개발 가능
---
## 6. 테스트 시나리오
| 시나리오 | 예상 결과 |
|----------|-----------|
| 형식이 잘못된 사업자등록번호 입력 | "유효하지 않은 사업자 등록번호입니다." |
| 이미 등록된 사업자등록번호 입력 | "이미 등록된 사업자등록번호입니다." |
| 사용 가능한 사업자등록번호 입력 | "사용 가능한 사업자 등록번호입니다." |
| 중복된 번호로 법인 등록 시도 | 등록 실패, 에러 메시지 표시 |
---
## 7. 작업 완료
- [x] PortalOrgService에 existsByCompRegNo 메서드 추가
- [x] OrgRegisterFacadeImpl.checkBusinessNumber 수정
- [x] 법인 등록 메서드들에 중복 체크 로직 추가
- [x] 빌드 검증 완료
-165
View File
@@ -1,165 +0,0 @@
# 네비게이션 바 디자인 Survey 기능 가이드
## 개요
직원 대상 네비게이션 바 디자인 선호도 조사를 위한 UI 기능입니다.
사용자가 A, B, C 등의 버튼을 클릭하면 실시간으로 네비게이션 바 디자인이 변경됩니다.
## 기능 활성화/비활성화
### PortalProperty 설정
DB의 `ptl_property` 테이블에서 `ui.design-survey` 값을 설정합니다.
```sql
-- 활성화
UPDATE ptl_property
SET property_value = 'true'
WHERE property_group_name = 'Portal' AND property_name = 'ui.design-survey';
-- 비활성화
UPDATE ptl_property
SET property_value = 'false'
WHERE property_group_name = 'Portal' AND property_name = 'ui.design-survey';
```
> 최초 페이지 로드 시 속성이 자동 생성됩니다 (기본값: `false`)
---
## 디자인 옵션 추가/수정 방법
### 파일 위치
`src/main/resources/templates/views/fragment/kjbank/header_container.html`
### DESIGN_OPTIONS 배열
디자인 옵션은 JavaScript의 `DESIGN_OPTIONS` 배열에서 관리됩니다:
```javascript
const DESIGN_OPTIONS = [
{
id: 'A', // 고유 식별자 (필수)
label: 'A (현재)', // 버튼에 표시될 텍스트 (필수)
isDefault: true, // 기본 선택 옵션 (선택, 하나만 true)
styles: {} // 적용할 CSS 스타일 (필수)
},
{
id: 'B',
label: 'B',
styles: {
'.logo-text': { 'font-size': '18px' },
'.nav-link': { 'margin': '0 20px' }
}
},
{
id: 'C',
label: 'C',
styles: {
'.nav-link': { 'margin': '0 20px', 'font-weight': 'bold' }
}
}
];
```
### 새 디자인 옵션 추가 예시
```javascript
// D 옵션 추가
{
id: 'D',
label: 'D',
styles: {
'.logo-text': {
'font-size': '16px',
'color': '#333333'
},
'.nav-link': {
'padding': '10px 24px',
'font-weight': '600'
}
}
}
```
### styles 객체 구조
```javascript
styles: {
'CSS 셀렉터': {
'CSS 속성': '값',
'CSS 속성2': '값2'
},
'다른 셀렉터': {
'CSS 속성': '값'
}
}
```
---
## 자주 사용되는 CSS 셀렉터
| 셀렉터 | 설명 |
|--------|------|
| `.logo-text` | 로고 텍스트 "API Portal" |
| `.nav-link` | 네비게이션 메뉴 링크 |
| `.nav-menu` | 네비게이션 메뉴 컨테이너 |
| `.header-content` | 헤더 콘텐츠 영역 |
| `.global-header` | 전체 헤더 |
| `.logo-wrapper` | 로고 래퍼 (이미지 + 텍스트) |
## 자주 사용되는 CSS 속성
| 속성 | 예시 값 | 설명 |
|------|---------|------|
| `font-size` | `18px`, `1.2rem` | 글자 크기 |
| `font-weight` | `400`, `500`, `600`, `bold` | 글자 두께 |
| `margin` | `0 20px`, `10px 15px` | 바깥 여백 |
| `padding` | `8px 16px` | 안쪽 여백 |
| `color` | `#333333`, `#0049b4` | 글자 색상 |
| `background` | `#ffffff`, `#f5f5f5` | 배경 색상 |
| `gap` | `12px`, `20px` | flex 아이템 간격 |
---
## 사용자 선택 저장
사용자의 디자인 선택은 브라우저의 `localStorage`에 저장됩니다.
- Key: `design-survey-selection`
- Value: 선택한 디자인 ID (예: `A`, `B`, `C`)
```javascript
// 저장된 선택 확인 (개발자 도구 콘솔)
localStorage.getItem('design-survey-selection')
// 선택 초기화
localStorage.removeItem('design-survey-selection')
```
---
## 관련 파일
| 파일 | 역할 |
|------|------|
| `GlobalControllerAdvice.java` | `designSurveyEnabled` ModelAttribute 제공 |
| `header_container.html` | Survey 바 HTML 및 DESIGN_OPTIONS 설정 |
| `_header.scss` | Survey 바 스타일 |
---
## 주의사항
1. **디자인 옵션 ID는 고유해야 합니다** - 중복 ID 사용 시 오동작
2. **isDefault는 하나의 옵션에만 설정** - 여러 개 설정 시 첫 번째만 적용
3. **CSS 속성명은 kebab-case 사용** - `fontSize` (X) → `font-size` (O)
4. **스타일 적용 시 `!important` 자동 추가** - 기존 스타일 덮어씀
5. **Survey 기능 종료 후** - `ui.design-survey``false`로 설정하여 비활성화
---
## 문의
기능 관련 문의는 개발팀에 연락해주세요.
File diff suppressed because it is too large Load Diff
@@ -1,105 +0,0 @@
flowchart TD
Start([법인 관리자가 사용자 초대]) --> GenerateToken[8글자 토큰 생성<br/>예: K7MNPQ2R<br/>UserInvitation 저장<br/>상태: PENDING]
GenerateToken --> SendEmail[이메일 발송<br/>- 링크 포함<br/>- 8글자 토큰 표시]
SendEmail --> UserType{사용자 유형}
%% ========================================
%% 신규 회원 플로우
%% ========================================
UserType -->|신규 회원<br/>미가입자| NewUserPath{초대 확인 방법}
NewUserPath -->|이메일 링크| EmailLink1[이메일 링크 클릭<br/>/signup/portalUser?invitation=CODE]
NewUserPath -->|일반 회원가입| DirectSignup[일반 회원가입 진행<br/>ROLE_USER로 생성]
EmailLink1 --> SignupPage1[회원가입 페이지<br/>- 이메일 자동 입력됨<br/>- 법인명 표시]
SignupPage1 --> RegisterForm[가입 정보 입력<br/>초대 수락]
RegisterForm --> RegisterComplete[회원가입 완료<br/>ROLE_CORP_USER로 생성<br/>상태: COMPLETED]
RegisterComplete --> NewUserEnd([가입 완료 안내])
DirectSignup --> Login4[로그인]
Login4 --> AfterLogin4[로그인 성공]
AfterLogin4 --> CheckInvitation
%% ========================================
%% 기존 회원 플로우
%% ========================================
UserType -->|기존 회원<br/>ROLE_USER| ExistingUserPath{초대 확인 방법}
ExistingUserPath -->|이메일 링크<br/>비로그인 상태| EmailLink2[이메일 링크 클릭<br/>/signup/decision?invitation=CODE]
ExistingUserPath -->|이메일 링크<br/>로그인 상태| EmailLinkLoggedIn[이메일 링크 클릭<br/>/signup/decision?invitation=CODE]
ExistingUserPath -->|직접 로그인| DirectLogin[로그인 페이지에서 로그인]
%% 비로그인 상태에서 이메일 링크 클릭
EmailLink2 --> DecisionPagePublic[초대 수락 페이지<br/>비로그인 상태]
DecisionPagePublic --> RedirectLogin2[로그인 유도]
RedirectLogin2 --> Login2[로그인]
Login2 --> AfterLogin2[로그인 성공]
%% 로그인 상태에서 이메일 링크 클릭 → 바로 수락 페이지
EmailLinkLoggedIn --> DecisionPageDirect[초대 수락 페이지<br/>/signup/decision_process<br/>초대 수락]
DecisionPageDirect --> UserDecision
%% 직접 로그인
DirectLogin --> Login3[로그인]
Login3 --> AfterLogin3[로그인 성공]
%% ========================================
%% 로그인 후 초대 확인 (PortalAuthenticationSuccessHandler)
%% ========================================
AfterLogin2 --> CheckInvitation[PortalAuthenticationSuccessHandler<br/>PENDING 초대 확인<br/>findFirstByInvitationEmailAndStatus]
AfterLogin3 --> CheckInvitation
CheckInvitation --> HasInvitation{PENDING 초대 존재?}
HasInvitation -->|없음| NormalHome[메인 페이지 이동]
HasInvitation -->|있음| SaveToSession[세션에 초대 정보 저장<br/>pendingInvitation=true<br/>pendingInvitationToken<br/>pendingInvitationOrgName]
SaveToSession --> RedirectHome[메인 페이지로 이동]
%% ========================================
%% 메인 페이지 팝업 (index.html)
%% ========================================
RedirectHome --> ShowPopup[팝업 표시<br/>OOO에서 초대가 도착했습니다<br/>초대를 확인하시겠습니까?]
ShowPopup --> PopupChoice{사용자 선택}
PopupChoice -->|확인| GoDecisionPage[초대 수락 페이지 이동<br/>/signup/decision?invitation=TOKEN]
PopupChoice -->|취소| StayHome[메인 페이지 유지<br/>다음 로그인 시 팝업 재표시]
StayHome --> NextLogin[다음 로그인]
NextLogin --> CheckInvitation
GoDecisionPage --> DecisionPage[초대 수락 페이지<br/>/signup/decision_process<br/>초대 수락]
DecisionPage --> UserDecision{사용자 선택}
UserDecision -->|수락| AcceptProcess[초대 수락 처리<br/>1. PortalOrg 변경<br/>2. ROLE_CORP_USER 전환<br/>3. 약관 재동의<br/>상태: COMPLETED]
UserDecision -->|거절| RejectProcess[초대 거절 처리<br/>상태: REJECTED]
AcceptProcess --> LogoutPrompt[로그아웃 후 재로그인 안내]
LogoutPrompt --> ExistingUserEnd([초대 수락 완료])
RejectProcess --> RejectEnd([초대 거절 완료<br/>다음 로그인 시 팝업 안 뜸])
%% ========================================
%% 스타일링
%% ========================================
style Start fill:#e1f5e1
style NewUserEnd fill:#c8e6c9
style ExistingUserEnd fill:#c8e6c9
style RejectEnd fill:#fff9c4
style NormalHome fill:#e3f2fd
style GenerateToken fill:#bbdefb
style AcceptProcess fill:#c5cae9
style RejectProcess fill:#ffecb3
style CheckInvitation fill:#ffe0b2
style SaveToSession fill:#ffe0b2
style ShowPopup fill:#f8bbd0
style PopupChoice fill:#f8bbd0
style StayHome fill:#e1bee7
style NextLogin fill:#e1bee7
-44
View File
@@ -1,44 +0,0 @@
flowchart TD
Start([법인 관리자가 사용자 초대 시작]) --> CheckUser{가입된 사용자인가?}
CheckUser -->|아니오| Error1[에러: 가입된 사용자만 초대 가능]
CheckUser -->|예| CheckRole{이미 법인 사용자인가?}
CheckRole -->|예| Error2[에러: 이미 기관에 등록된 사용자]
CheckRole -->|아니오 ROLE_USER| CreateInvitation[8글자 토큰 생성<br/>UserInvitation 저장<br/>상태: PENDING]
CreateInvitation --> SendEmail[이메일 발송<br/>UserInvitationEvent]
SendEmail --> UserReceive{사용자가 토큰 받음}
UserReceive -->|폐쇄망| ManualInput[8글자 토큰 직접 입력<br/>예: K7MNPQ2R]
UserReceive -->|이메일 링크| EmailLink[Base64 인코딩된<br/>링크 클릭]
ManualInput --> DecodeToken[토큰 디코딩<br/>decodeInvitationToken]
EmailLink --> DecodeToken
DecodeToken --> ValidateToken{토큰 유효성 검증}
ValidateToken -->|무효| Error3[에러: 유효하지 않은 초대]
ValidateToken -->|유효 & PENDING| ShowDecision[약관 동의 페이지<br/>/signup/decision]
ShowDecision --> UserDecision{사용자 선택}
UserDecision -->|수락| ProcessAccept[processInvitation: accept]
UserDecision -->|거절| ProcessReject[processInvitation: reject]
ProcessAccept --> UpdateUser[1. PortalOrg 변경<br/>2. ROLE_CORP_USER로 전환<br/>3. 약관 재동의 PRIVACY_COLLECT_ORG]
UpdateUser --> CompleteInvitation[Invitation 상태:<br/>COMPLETED + completeDate]
CompleteInvitation --> Success([초대 수락 완료<br/>로그아웃 후 재로그인])
ProcessReject --> RejectInvitation[Invitation 상태:<br/>REJECTED + completeDate]
RejectInvitation --> End([초대 거절 완료])
style Start fill:#e1f5e1
style Success fill:#c8e6c9
style End fill:#fff9c4
style Error1 fill:#ffcdd2
style Error2 fill:#ffcdd2
style Error3 fill:#ffcdd2
style CreateInvitation fill:#bbdefb
style UpdateUser fill:#c5cae9
-122
View File
@@ -1,122 +0,0 @@
sequenceDiagram
actor 법인관리자
participant Portal as Portal UI
participant UserManFacade
participant UserInvitationRepo as UserInvitation Repository
participant MessageService as Message Handler
participant Email as Email System
actor 초대받은사용자
participant AuthHandler as PortalAuthenticationSuccessHandler
participant IndexController
participant MainPage as 메인 페이지 (index.html)
participant UserRegisterController
participant UserRegisterFacade
participant PortalUserRepo as PortalUser Repository
participant PortalOrgRepo as PortalOrg Repository
%% ========================================
%% 1. 초대 발송
%% ========================================
법인관리자->>Portal: 사용자 이메일 입력<br/>초대 요청
Portal->>UserManFacade: sendInvitation(sender, emailAddr)
UserManFacade->>PortalUserRepo: findByEmailAddr(emailAddr)
alt 사용자가 없거나 이미 법인 사용자
UserManFacade-->>Portal: 에러 반환
Portal-->>법인관리자: 초대 불가 알림
else 일반 사용자 ROLE_USER 또는 비회원
UserManFacade->>UserManFacade: generateEightCharToken()<br/>예: K7MNPQ2R
UserManFacade->>UserInvitationRepo: save(UserInvitation)<br/>status: PENDING<br/>expiresOn: 7일 후
UserManFacade->>MessageService: publishEvent(UserInvitationEvent)
MessageService->>Email: 초대 이메일 발송<br/>(법인명, 관리자명, 토큰)
UserManFacade-->>Portal: 초대 완료
Portal-->>법인관리자: 초대 성공 알림
end
Email->>초대받은사용자: 초대 이메일 수신
%% ========================================
%% 2-A. 로그인 상태에서 이메일 링크 클릭 (바로 수락 페이지)
%% ========================================
alt 로그인 상태에서 이메일 링크 클릭
초대받은사용자->>Portal: 이메일 링크 클릭<br/>/signup/decision?invitation=TOKEN
Portal->>UserRegisterController: showDecisionPage(invitationToken)
UserRegisterController->>UserInvitationRepo: findByToken(decodedToken)
UserRegisterController-->>Portal: 약관 동의 페이지
Portal-->>초대받은사용자: 초대 수락/거절 화면<br/>(바로 표시, 팝업 없음)
Note over 초대받은사용자: 6. 초대 수락/거절 처리로 이동
end
%% ========================================
%% 2-B. 로그인 (기존 회원 또는 신규 가입 후)
%% ========================================
alt 기존 회원 (비로그인 상태)
초대받은사용자->>Portal: 로그인
else 비회원이 일반 회원가입 후
초대받은사용자->>Portal: 회원가입 (ROLE_USER)
초대받은사용자->>Portal: 로그인
end
%% ========================================
%% 3. 로그인 성공 처리 (AuthenticationSuccessHandler)
%% ========================================
Portal->>AuthHandler: onAuthenticationSuccess()
AuthHandler->>AuthHandler: 이메일 인증, 휴면계정,<br/>비밀번호 만료 체크
AuthHandler->>UserInvitationRepo: findFirstByInvitationEmailAndStatus<br/>(loginId, PENDING)
alt PENDING 초대 없음
AuthHandler->>Portal: redirect to /
Portal-->>초대받은사용자: 메인 페이지 (팝업 없음)
else PENDING 초대 있음 & 만료 안됨
AuthHandler->>PortalOrgRepo: findById(invitation.orgId)
PortalOrgRepo-->>AuthHandler: PortalOrg (기관명)
AuthHandler->>AuthHandler: session.setAttribute<br/>("pendingInvitation", true)<br/>("pendingInvitationToken", token)<br/>("pendingInvitationOrgName", orgName)
AuthHandler->>Portal: redirect to /
end
%% ========================================
%% 4. 메인 페이지 팝업 표시
%% ========================================
Portal->>IndexController: GET /
IndexController->>MainPage: render index.html
MainPage->>MainPage: 세션에서 pendingInvitation 확인
alt pendingInvitation == true
MainPage-->>초대받은사용자: 팝업 표시<br/>"[기관명]에서 초대가 도착했습니다.<br/>초대를 확인하시겠습니까?"
end
%% ========================================
%% 5. 사용자 팝업 선택
%% ========================================
alt 팝업에서 "확인" 클릭
초대받은사용자->>Portal: /signup/decision?invitation=TOKEN
Portal->>UserRegisterController: showDecisionPage(invitationToken)
UserRegisterController->>UserInvitationRepo: findByToken(decodedToken)
UserRegisterController-->>Portal: 약관 동의 페이지
Portal-->>초대받은사용자: 초대 수락/거절 화면
else 팝업에서 "취소" 클릭
Note over 초대받은사용자,MainPage: 메인 페이지 유지<br/>다음 로그인 시 팝업 재표시
end
%% ========================================
%% 6. 초대 수락/거절 처리
%% ========================================
초대받은사용자->>Portal: 수락 or 거절 선택
Portal->>UserRegisterController: processInvitation(action)
UserRegisterController->>UserRegisterFacade: processInvitation(action, invitation)
alt 수락
UserRegisterFacade->>PortalUserRepo: findByEmailAddr()
UserRegisterFacade->>PortalUserRepo: updateUserToCorpUser()<br/>(PortalOrg 변경<br/>ROLE_CORP_USER 전환)
UserRegisterFacade->>UserInvitationRepo: save(invitation)<br/>status: COMPLETED<br/>completeDate: now()
UserRegisterFacade-->>Portal: 수락 성공
Portal-->>초대받은사용자: 성공 메시지<br/>로그아웃 후 재로그인 안내
Note over 초대받은사용자: 다음 로그인 시<br/>PENDING 초대 없음 → 팝업 안 뜸
else 거절
UserRegisterFacade->>UserInvitationRepo: save(invitation)<br/>status: REJECTED<br/>completeDate: now()
UserRegisterFacade-->>Portal: 거절 처리 완료
Portal-->>초대받은사용자: 거절 완료 메시지
Note over 초대받은사용자: 다음 로그인 시<br/>REJECTED 상태 → 팝업 안 뜸
end
-44
View File
@@ -1,44 +0,0 @@
stateDiagram-v2
[*] --> 초대생성: 법인관리자가 초대 시작
초대생성 --> PENDING: 토큰 생성 및 저장
PENDING --> 이메일발송: 초대 이메일 발송
state 로그인후팝업 {
이메일발송 --> 로그인: 사용자 로그인
로그인 --> 초대확인: AuthSuccessHandler
초대확인 --> 세션저장: 세션에 초대 정보 저장
세션저장 --> 팝업표시: 메인 페이지 팝업
팝업표시 --> 팝업확인: 확인 클릭
팝업표시 --> 팝업취소: 취소 클릭
팝업취소 --> 로그인: 다음 로그인 시 재표시
}
팝업확인 --> 초대수락: 수락 페이지 이동
초대수락 --> COMPLETED: 수락
초대수락 --> REJECTED: 거절
COMPLETED --> [*]: 초대 완료
REJECTED --> [*]: 초대 거절
PENDING --> CANCELED: 관리자 취소
PENDING --> EXPIRED: 만료일 경과
CANCELED --> [*]: 초대 취소
EXPIRED --> [*]: 초대 만료
note right of PENDING
PENDING 상태일 때만
로그인 시 팝업 표시
end note
note right of REJECTED
REJECTED 상태가 되면
더 이상 팝업 표시 안 함
end note
note right of COMPLETED
COMPLETED 상태가 되면
더 이상 팝업 표시 안 함
ROLE_CORP_USER로 전환됨
end note
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+84 -466
View File
@@ -1,33 +1,30 @@
{
"name": "eapim-portal",
"version": "1.0.0",
"lockfileVersion": 2,
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "eapim-portal",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright": "^1.57.0"
},
"devDependencies": {
"sass": "^1.69.5"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
@@ -37,25 +34,25 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
@@ -74,9 +71,9 @@
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
@@ -95,9 +92,9 @@
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
@@ -116,9 +113,9 @@
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
@@ -137,9 +134,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
@@ -158,9 +155,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
@@ -179,9 +176,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
@@ -200,9 +197,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
@@ -221,9 +218,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
@@ -242,9 +239,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
@@ -263,9 +260,9 @@
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
@@ -284,9 +281,9 @@
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
@@ -305,9 +302,9 @@
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
@@ -325,82 +322,37 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 14.16.0"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true,
"license": "MIT"
},
@@ -429,32 +381,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -464,57 +390,27 @@
"optional": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8.6"
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
@@ -522,21 +418,21 @@
}
},
"node_modules/sass": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.1.tgz",
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz",
"integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
@@ -551,284 +447,6 @@
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
},
"dependencies": {
"@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"optional": true,
"requires": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
}
},
"@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"dev": true,
"optional": true
},
"@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"dev": true,
"optional": true
},
"@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"dev": true,
"optional": true
},
"@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"dev": true,
"optional": true
},
"@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"dev": true,
"optional": true
},
"@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"dev": true,
"optional": true
},
"@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"dev": true,
"optional": true
},
"braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"optional": true,
"requires": {
"fill-range": "^7.1.1"
}
},
"chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"requires": {
"readdirp": "^4.0.1"
}
},
"detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"optional": true
},
"fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"optional": true,
"requires": {
"to-regex-range": "^5.0.1"
}
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
"immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"dev": true
},
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"optional": true
},
"is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"optional": true,
"requires": {
"is-extglob": "^2.1.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"optional": true
},
"micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"optional": true,
"requires": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
}
},
"node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"optional": true
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"optional": true
},
"playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"requires": {
"fsevents": "2.3.2",
"playwright-core": "1.57.0"
}
},
"playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="
},
"readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true
},
"sass": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.1.tgz",
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"dev": true,
"requires": {
"@parcel/watcher": "^2.4.1",
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
}
},
"source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"optional": true,
"requires": {
"is-number": "^7.0.0"
}
}
}
}
+1 -4
View File
@@ -13,8 +13,5 @@
"sass": "^1.69.5"
},
"author": "",
"license": "ISC",
"dependencies": {
"playwright": "^1.57.0"
}
"license": "ISC"
}
+1 -5
View File
@@ -4,8 +4,4 @@ include 'elink-online-core-jpa'
include 'elink-portal-common'
project (':elink-online-core-jpa').projectDir = new File(settingsDir, "../eapim-online/elink-online-core-jpa")
project (':elink-portal-common').projectDir = new File(settingsDir, "../elink-portal-common")
//include 'kjb-safedb'
//project (':kjb-safedb').projectDir = new File(settingsDir, "../kjb-safedb")
project (':elink-portal-common').projectDir = new File(settingsDir, "../elink-portal-common")
@@ -0,0 +1,67 @@
package com.eactive.apim.portal.apps;
import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Readiness probe.
*
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
* This endpoint validates both EMS and Gateway datasources via JDBC
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
* requests that depend on the database.
*
* <p>HTTP 200 + JSON when all checks pass.
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
* status so CI/CD logs the failing dependency.
*/
@RestController
public class ReadinessController {
private static final int VALIDATION_TIMEOUT_SECONDS = 2;
private final DataSource portalDataSource;
private final DataSource gatewayDataSource;
public ReadinessController(
@Qualifier("portalDataSource") DataSource portalDataSource,
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
this.portalDataSource = portalDataSource;
this.gatewayDataSource = gatewayDataSource;
}
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> ready() {
String ems = check(portalDataSource);
String gw = check(gatewayDataSource);
boolean ok = "UP".equals(ems) && "UP".equals(gw);
String body = "{"
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
+ "\"ems\":\"" + ems + "\","
+ "\"gateway\":\""+ gw + "\""
+ "}";
return ResponseEntity.status(ok ? 200 : 503)
.contentType(MediaType.APPLICATION_JSON)
.body(body);
}
private static String check(DataSource ds) {
if (ds == null) return "DOWN:NO_DATASOURCE";
try (Connection c = ds.getConnection()) {
return c.isValid(VALIDATION_TIMEOUT_SECONDS) ? "UP" : "DOWN:INVALID";
} catch (SQLException e) {
String msg = e.getMessage() == null ? "" : e.getMessage();
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
}
}
}
@@ -56,97 +56,99 @@ public class ApiTesterFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
if (httpServletRequest.getRequestURI().contains("/api/call-api")) {
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url");
// "/api/call-api" 경로가 아닌 경우 필터링하지 않고 다음 필터로 넘어감
if (!httpServletRequest.getRequestURI().contains("/api/call-api")) {
chain.doFilter(request, response);
return;
}
if (url.contains("/api/v1/oauth/token")){
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url");
// 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]);
}
}
String scope = params.getOrDefault("scope", "default");
if (url.contains("/api/v1/oauth/token")){
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
String token = "{\n" +
" \"access_token\": \"kjbank_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")) {
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
} else {
String mockUrl = apiSpecInfoDto.getMockUrl();
String method = apiSpecInfoDto.getApiMethod();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
Map<String, String> headers = new HashMap<>();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
}
Map<String, String[]> paramMap = new HashMap<>();
String originalUrl = headers.get("original-url");
//sprlit original url with ? get the second part then split with & put into paramMap
String[] originalUrlArr = originalUrl.split("\\?");
if (originalUrlArr.length > 1) {
String[] paramArr = originalUrlArr[1].split("&");
for (String param : paramArr) {
String[] paramKeyValue = param.split("=");
if (paramKeyValue.length > 1) {
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
}
}
}
headers.remove("original-url");
headers.remove("original-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);
// 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]);
}
}
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 {
chain.doFilter(request, response);
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
} else {
String mockUrl = apiSpecInfoDto.getMockUrl();
String method = apiSpecInfoDto.getApiMethod();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
Map<String, String> headers = new HashMap<>();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
}
Map<String, String[]> paramMap = new HashMap<>();
String originalUrl = headers.get("original-url");
//sprlit original url with ? get the second part then split with & put into paramMap
String[] originalUrlArr = originalUrl.split("\\?");
if (originalUrlArr.length > 1) {
String[] paramArr = originalUrlArr[1].split("&");
for (String param : paramArr) {
String[] paramKeyValue = param.split("=");
if (paramKeyValue.length > 1) {
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
}
}
}
headers.remove("original-url");
headers.remove("original-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);
}
}
}
@@ -3,8 +3,9 @@ package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.var;
import java.util.List;
import java.util.stream.Collectors;
@@ -18,12 +19,12 @@ public class ApiPermissionFilter {
if (isAuthenticated) {
// null 체크 추가
var user = SecurityUtil.getPortalAuthenticatedUser();
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user != null && user.getRoleCode() != null) {
roleCode = user.getRoleCode().toString();
}
var userOrg = SecurityUtil.getUserOrg();
PortalOrgDTO userOrg = SecurityUtil.getUserOrg();
if (userOrg != null) {
org = userOrg.getId();
}
@@ -44,12 +45,12 @@ public class ApiPermissionFilter {
if (isAuthenticated) {
// null 체크 추가
var user = SecurityUtil.getPortalAuthenticatedUser();
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user != null && user.getRoleCode() != null) {
roleCode = user.getRoleCode().toString();
}
var userOrg = SecurityUtil.getUserOrg();
PortalOrgDTO userOrg = SecurityUtil.getUserOrg();
if (userOrg != null) {
org = userOrg.getId();
}
@@ -6,7 +6,11 @@ import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -29,9 +33,12 @@ public class InquiryController {
public static final String APPS_COMMUNITY_MAIN_INQUIRY_FORM = "apps/community/mainInquiryForm";
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
private final InquiryFacade inquiryFacade;
private final InquiryCommentFacade inquiryCommentFacade;
public InquiryController(InquiryFacade inquiryFacade) {
public InquiryController(InquiryFacade inquiryFacade,
InquiryCommentFacade inquiryCommentFacade) {
this.inquiryFacade = inquiryFacade;
this.inquiryCommentFacade = inquiryCommentFacade;
}
/**
@@ -42,8 +49,13 @@ public class InquiryController {
Model model) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
Page<InquiryDTO> inquiries = inquiryFacade.getInquiries(search, pageable, user);
List<String> ids = inquiries.getContent().stream()
.map(InquiryDTO::getId)
.collect(Collectors.toList());
Map<String, Long> commentCounts = inquiryCommentFacade.countActiveByInquiryIds(ids);
model.addAttribute("inquiries", inquiries.getContent());
model.addAttribute("page", inquiries);
model.addAttribute("commentCounts", commentCounts);
return "apps/community/mainInquiryList";
}
@@ -57,8 +69,9 @@ public class InquiryController {
return REDIRECT_INQUIRY;
}
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
InquiryDTO inquiry = inquiryFacade.getAccessibleInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
model.addAttribute("inquiry", inquiry);
model.addAttribute("commentWritable", !"CLOSED".equals(inquiry.getInquiryStatus()));
return "apps/community/mainInquiryDetail";
}
@@ -5,13 +5,10 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.NotEmpty;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
@Data
@NoArgsConstructor
@@ -50,8 +47,6 @@ public class InquiryDTO {
private LocalDateTime lastModifiedDate;
private List<MultipartFile> files;
private String attachFile;
private String maskedInquirerName;
@@ -15,6 +15,8 @@ public interface InquiryFacade {
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
void createInquiry(InquiryDTO inquiryDTO) throws IOException;
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException;
@@ -6,12 +6,10 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryAdminNotifier;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -20,14 +18,15 @@ import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class InquiryFacadeImpl implements InquiryFacade {
private final FileService fileService;
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final InquiryAdminNotifier inquiryAdminNotifier;
@Override
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
@@ -79,55 +78,30 @@ public class InquiryFacadeImpl implements InquiryFacade {
return inquiryMapper.map(inquiry);
}
@Override
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
return inquiryMapper.map(inquiry);
}
@Override
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
FileInfo file = null;
if (!inquiryDTO.getFiles().isEmpty()) {
try {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
FileTypeContext.setFileType("qna");
file = fileService.createFile(inquiryDTO.getFiles());
} finally {
FileService.clearInternalUserContext();
}
}
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
if (file != null) {
inquiry.setAttachFile(file.getFileId());
}
inquiry.setInquirer(SecurityUtil.getPortalAuthenticatedUser());
inquiry.setInquirer(current);
inquiryService.createInquiry(inquiry);
HashMap<String, Object> params = new HashMap<>();
Map<String, Object> params = new HashMap<>();
params.put("inquiryId", inquiry.getId());
params.put("subject", inquiry.getInquirySubject());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("writerName", current.getUserName());
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
}
@Override
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException {
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
if (!inquiryDTO.getFiles().isEmpty() && !inquiryDTO.getFiles().get(0).isEmpty()) {
try {
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
FileTypeContext.setFileType("qna");
FileInfo file = fileService.createFile(inquiryDTO.getFiles());
if(file != null) {
inquiryDTO.setAttachFile(file.getFileId());
}
} finally {
FileService.clearInternalUserContext();
}
} else if (existingInquiry.getAttachFile() != null && inquiryDTO.getAttachFile() == null) {
fileService.deleteFile(inquiryDTO.getAttachFile());
inquiryDTO.setAttachFile(null);
}
inquiryDTO.setAttachFile(existingInquiry.getAttachFile());
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
inquiry.setInquirer(existingInquiry.getInquirer());
@@ -4,6 +4,9 @@ package com.eactive.apim.portal.apps.community.qna.service;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.qna.entity.Inquiry;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -90,5 +93,36 @@ public class InquiryService {
return inquiryRepository.findByInquirerAndId(user, id).orElseThrow(() -> new NotFoundException("내 질문을 찾을 수 없습니다."));
}
/**
* 본인 또는 같은 법인 소속 이용자가 작성한 Q&A를 조회한다.
* - ROLE_USER: 본인 글만 접근 가능
* - ROLE_CORP_*: 같은 법인 소속 이용자가 작성한 글까지 접근 가능
*/
@Transactional(readOnly = true)
public Inquiry getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
Inquiry inquiry = getInquiry(id);
if (!isAccessibleByUser(user, inquiry)) {
throw new NotFoundException("문의글을 찾을 수 없습니다. id=" + id);
}
return inquiry;
}
private boolean isAccessibleByUser(PortalAuthenticatedUser user, Inquiry inquiry) {
PortalUser inquirer = inquiry.getInquirer();
if (inquirer == null || user == null) {
return false;
}
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
return true;
}
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
return false;
}
PortalOrg userOrg = user.getPortalOrg();
PortalOrg inquirerOrg = inquirer.getPortalOrg();
return userOrg != null && inquirerOrg != null
&& userOrg.getId() != null
&& userOrg.getId().equals(inquirerOrg.getId());
}
}
@@ -24,7 +24,6 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.var;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
@@ -252,7 +251,7 @@ public class AccountController {
@GetMapping("/mypage/org-transfer")
public String showOrgTransferPage(Model model) {
try {
var authenticatedUser = SecurityUtil.getPortalAuthenticatedUser();
PortalAuthenticatedUser authenticatedUser = SecurityUtil.getPortalAuthenticatedUser();
PortalUserDTO user = userFacade.findById(authenticatedUser.getId());
if (user.getRoleCode() != PortalUserEnums.RoleCode.ROLE_USER) {
@@ -282,7 +281,7 @@ public class AccountController {
HttpServletResponse response) {
Map<String, Object> result = new HashMap<>();
try {
var currentUser = SecurityUtil.getPortalAuthenticatedUser();
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
if (currentUser.getRoleCode() != PortalUserEnums.RoleCode.ROLE_USER) {
result.put("status", "ERROR");
@@ -3,7 +3,7 @@ package com.eactive.apim.portal.apps.user.dto;
import com.eactive.apim.portal.common.validator.AuthNumberMatch;
import com.eactive.apim.portal.common.validator.CellPhone;
import com.eactive.apim.portal.common.validator.PasswordMatch;
import com.eactive.apim.portal.common.validator.PasswordRuleForKjbank;
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotEmpty;
@@ -12,7 +12,7 @@ import org.hibernate.validator.constraints.NotEmpty;
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
@PasswordMatch(input = "password", confirm = "password2")
@Data
@PasswordRuleForKjbank(password = "password", loginId = "loginId", mobile = "mobileNumber")
@PasswordRuleForDjbank(password = "password", loginId = "loginId", mobile = "mobileNumber")
public class PortalUserRegistrationDTO {
/**
@@ -1,7 +1,7 @@
package com.eactive.apim.portal.apps.user.dto;
import com.eactive.apim.portal.common.validator.PasswordMatch;
import com.eactive.apim.portal.common.validator.PasswordRuleForKjbank;
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
import com.eactive.apim.portal.common.validator.UniqueId;
import com.eactive.apim.portal.portaluser.entity.UserStatus;
import lombok.Data;
@@ -13,7 +13,7 @@ import java.io.Serializable;
@PasswordMatch(input = "password", confirm = "password2")
@Data
@PasswordRuleForKjbank(loginId = "userId", password = "password", mobile = "mobilePhone")
@PasswordRuleForDjbank(loginId = "userId", password = "password", mobile = "mobilePhone")
public class UserRegisterDTO implements Serializable {
@@ -1,7 +1,7 @@
package com.eactive.apim.portal.apps.user.validator;
import com.eactive.apim.portal.common.validator.PasswordRuleForKbankValidator;
import com.eactive.apim.portal.common.validator.PasswordRuleForKjbankValidator;
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbankValidator;
import org.springframework.stereotype.Component;
@Component
@@ -17,7 +17,7 @@ public class PasswordValidator {
}
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
PasswordRuleForKjbankValidator validator = new PasswordRuleForKjbankValidator();
PasswordRuleForDjbankValidator validator = new PasswordRuleForDjbankValidator();
return validator.isValid(password, loginId, mobileNumber);
}
@@ -1,24 +1,41 @@
package com.eactive.apim.portal.common.breadcrumb;
import java.util.HashMap;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.yaml.snakeyaml.Yaml;
@Service
public class PageService {
private static final Logger log = LoggerFactory.getLogger(PageService.class);
private final PageStructure pageStructure;
@Value("${portal.dev.hot-reload-pages:false}")
private boolean hotReloadEnabled;
private volatile long lastReloadedMtime = 0L;
private volatile Path watchedPath;
public PageService(PageStructure pageStructure) {
this.pageStructure = pageStructure;
}
public List<Map> getBreadcrumb(String currentPath) {
// URL에서 기본 경로와 쿼리 파라미터 분리
maybeReloadPages();
String basePath = currentPath.contains("?") ?
currentPath.substring(0, currentPath.indexOf("?")) : currentPath;
Map<String, String> queryParams = parseQueryParams(currentPath);
@@ -29,12 +46,59 @@ public class PageService {
}
public String getPageName(String currentPath) {
maybeReloadPages();
String basePath = currentPath.contains("?") ?
currentPath.substring(0, currentPath.indexOf("?")) : currentPath;
Map<String, String> queryParams = parseQueryParams(currentPath);
return findPageName(pageStructure.getHome(), basePath, queryParams);
}
// -----------------------------------------------------------------------------
// Dev-only hot reload of the `page:` tree.
// Toggle via `portal.dev.hot-reload-pages: true` in any active profile yml.
// Only the page tree (breadcrumb / menu names) reloads — `portal.pages` route
// mappings are still registered at boot and require a restart.
// -----------------------------------------------------------------------------
private void maybeReloadPages() {
if (!hotReloadEnabled) return;
try {
Path p = resolveYmlPath();
if (p == null) return;
long mtime = Files.getLastModifiedTime(p).toMillis();
if (mtime == lastReloadedMtime) return;
try (InputStream is = Files.newInputStream(p)) {
Yaml yaml = new Yaml();
Map<String, Object> root = yaml.load(is);
Object page = (root != null) ? root.get("page") : null;
if (page instanceof Map) {
Object home = ((Map<?, ?>) page).get("home");
if (home instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> homeMap = (Map<String, Object>) home;
pageStructure.setHome(homeMap);
lastReloadedMtime = mtime;
log.debug("[PageService] page tree reloaded from {} (mtime={})", p, mtime);
}
}
}
} catch (Exception e) {
log.warn("[PageService] page tree reload failed: {}", e.toString());
}
}
private Path resolveYmlPath() {
if (watchedPath != null) return watchedPath;
try {
ClassPathResource res = new ClassPathResource("application.yml");
if (!res.exists() || !res.isFile()) return null;
watchedPath = res.getFile().toPath();
return watchedPath;
} catch (IOException e) {
return null;
}
}
private Map<String, String> parseQueryParams(String path) {
Map<String, String> params = new HashMap<>();
if (path.contains("?")) {
@@ -52,7 +116,6 @@ public class PageService {
private String findPageName(Map<String, Object> currentNode, String basePath, Map<String, String> queryParams) {
String path = (String) currentNode.get("path");
// 기본 경로가 일치하는지 확인
if (path != null) {
String nodePath = path.contains("?") ?
path.substring(0, path.indexOf("?")) : path;
@@ -63,7 +126,6 @@ public class PageService {
}
}
// 자식 노드들을 재귀적으로 검색
if (currentNode.containsKey("children")) {
Map<String, Object> children = (Map<String, Object>) currentNode.get("children");
for (Map.Entry<String, Object> entry : children.entrySet()) {
@@ -116,7 +178,6 @@ public class PageService {
}
private boolean matchesQueryParams(Map<String, String> nodeParams, Map<String, String> requestParams) {
// 특별히 tab 파라미터에 대해서만 체크
String nodeTab = nodeParams.get("tab");
String requestTab = requestParams.get("tab");
@@ -125,4 +186,4 @@ public class PageService {
}
return Objects.equals(nodeTab, requestTab);
}
}
}
@@ -0,0 +1,24 @@
package com.eactive.apim.portal.common.validator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PasswordRuleForDjbank {
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String password();
String loginId();
String mobile();
}
@@ -0,0 +1,144 @@
package com.eactive.apim.portal.common.validator;
import org.apache.commons.beanutils.PropertyUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Created by Sungpil Hyun
*/
public class PasswordRuleForDjbankValidator implements ConstraintValidator<PasswordRuleForDjbank, Object> {
// 최소 8자, 최대 20자 상수 선언
private static final int MIN = 8;
private static final int MAX = 20;
private String password;
private String loginId;
private String mobileNumber;
// 3자리 연속 문자 정규식
private static final String SAMEPT = "(\\w)\\1\\1";
// 공백 문자 정규식
private static final String BLANKPT = "(\\s)";
@Override
public void initialize(PasswordRuleForDjbank constraintAnnotation) {
this.password = constraintAnnotation.password();
this.loginId = constraintAnnotation.loginId();
this.mobileNumber = constraintAnnotation.mobile();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
String passwordValue = null;
String loginIdValue = null;
String mobileNumberValue = null;
try {
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
} catch (Exception e) {
return false;
}
return isValid(passwordValue, loginIdValue, mobileNumberValue);
}
public boolean isValid(String password, String loginId, String mobileNumber) {
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
// 정규식 검사객체
Matcher matcher;
// 공백 체크
if (password == null || "".equals(password)) {
return false;
}
// ASCII 문자 비교를 위한 UpperCase
String tmpPw = password.toUpperCase();
// 문자열 길이
int strLen = tmpPw.length();
// 글자 길이 체크
if (strLen > 20 || strLen < 8) {
return false;
}
if (loginId != null && !loginId.isEmpty()) {
String[] loginParts = loginId.split("@");
if (loginParts.length > 0) {
String username = loginParts[0].toUpperCase();
if (tmpPw.contains(username)) {
return false;
}
}
}
// Mobile number validation
if (mobileNumber != null && !mobileNumber.isEmpty()) {
String[] mobileParts = mobileNumber.split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return false;
}
}
}
// 공백 체크
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 비밀번호 정규식 체크
matcher = Pattern.compile(REGEX).matcher(tmpPw);
if (!matcher.find()) {
return false;
}
// 동일한 문자 3개 이상 체크
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 연속된 문자 / 숫자 3개 이상 체크
// ASCII Char를 담을 배열 선언
int[] tmpArray = new int[strLen];
// Make Array
for (int i = 0; i < strLen; i++) {
tmpArray[i] = tmpPw.charAt(i);
}
// Validation Array
for (int i = 0; i < strLen - 2; i++) {
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
return false;
}
}
// Validation Complete
return true;
}
static boolean isContinuous(int first, int third) {
// 첫 글자 A-Z / 0-9
return (first > 47 && third < 58) || (first > 64 && third < 91);
}
static boolean isContinuous(int first, int second, int third) {
// 배열의 연속된 수 검사
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
}
}
@@ -4,7 +4,7 @@ import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Constraint(validatedBy = PasswordRuleForKjbankValidator.class)
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@@ -27,6 +27,7 @@ public class BaseDatasourceConfiguration {
protected DataSource jndiLookup(String jndiName) throws NamingException {
JndiObjectFactoryBean bean = new JndiObjectFactoryBean();
bean.setJndiName(jndiName);
bean.setResourceRef(true);
bean.setProxyInterface(DataSource.class);
bean.afterPropertiesSet();
return (DataSource) bean.getObject();
@@ -9,7 +9,6 @@ import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
@@ -20,7 +19,6 @@ import javax.sql.DataSource;
@Configuration
@EntityScan(basePackages = {"com.eactive.apim.gateway"})
@EnableJpaRepositories(basePackages = "com.eactive.apim.gateway", repositoryBaseClass = BaseRepositoryImpl.class, entityManagerFactoryRef = "gatewayEntityManagerFactory")
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
@Slf4j
public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration {
private final Environment env;
@@ -59,6 +57,8 @@ public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration
} catch (NamingException e) {
log.warn("JNDI lookup failed.", e);
}
} else {
log.warn("gateway.datasource.jndi-name 이 설정되어 있지 않음 - 직접 접속 모드로 진행");
}
log.info("개발환경을 위해 JNDI 대신 직접 DB 접속 시도");
@@ -1,6 +1,6 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.custom.config.KjbPasswordEncoder;
import com.eactive.apim.portal.custom.config.DjbPasswordEncoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -11,6 +11,6 @@ public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// return new StandardPasswordEncoder();
return new KjbPasswordEncoder();
return new DjbPasswordEncoder();
}
}
@@ -19,8 +19,6 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Base64;
@Service
@Transactional
@RequiredArgsConstructor
@@ -31,23 +29,12 @@ public class PortalAuthenticationManager implements AuthenticationManager {
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private String decodePassword(String encodedPassword) {
try {
// Reverse the string and decode from Base64
String reversed = new StringBuilder(encodedPassword).reverse().toString();
return new String(Base64.getDecoder().decode(reversed));
} catch (Exception e) {
throw new BadCredentialsException("Password decoding failed", e);
}
}
@Override
@Transactional(noRollbackFor = {AuthenticationException.class})
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
String username = token.getName();
String encodedPassword = token.getCredentials().toString();
String decodedPassword = decodePassword(encodedPassword);
String password = token.getCredentials().toString();
PortalAuthenticatedUser user = (PortalAuthenticatedUser) portalUserAuthService.loadUserByUsername(username);
@@ -62,9 +49,9 @@ public class PortalAuthenticationManager implements AuthenticationManager {
// 비밀번호 검증
if (!passwordEncoder.matches(decodedPassword, user.getPassword())) {
if (!passwordEncoder.matches(password, user.getPassword())) {
// 암호화가 되어 있지 않을 수도 있어 그대로 비교 검증
if (!user.getPassword().equals(decodedPassword)) {
if (!user.getPassword().equals(password)) {
throw new BadCredentialsException("아이디 또는 비밀번호가 일치하지 않습니다.");
}
}
@@ -72,7 +59,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
// DORMANT 상태 체크
if (user.getUserStatus().equals(PortalUserEnums.UserStatus.DORMANT)) {
UsernamePasswordAuthenticationToken authorityToken =
new UsernamePasswordAuthenticationToken(user, decodedPassword, user.getAuthorities());
new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
authorityToken.setDetails(user);
SecurityContextHolder.getContext().setAuthentication(authorityToken);
return authorityToken;
@@ -101,7 +88,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
UsernamePasswordAuthenticationToken authorityToken =
new UsernamePasswordAuthenticationToken(user, decodedPassword, user.getAuthorities());
new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
authorityToken.setDetails(user);
SecurityContextHolder.getContext().setAuthentication(authorityToken);
return authorityToken;
@@ -70,6 +70,8 @@ public class PortalDatasourceConfiguration extends BaseDatasourceConfiguration {
} catch (NamingException e) {
log.warn("JNDI lookup failed.", e);
}
} else {
log.warn("ems.datasource.jndi-name 이 설정되어 있지 않음 - 직접 접속 모드로 진행");
}
if (dataSource == null) {
@@ -0,0 +1,24 @@
package com.eactive.apim.portal.custom.config;
//import com.eactive.ext.djb.safedb.DjbSafedbWrapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
public class DjbPasswordEncoder implements PasswordEncoder {
private final BCryptPasswordEncoder bcryptEncoder = new BCryptPasswordEncoder();
@Override
public String encode(CharSequence rawPassword) {
String bcryptHash = bcryptEncoder.encode(rawPassword);
return bcryptHash;
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return bcryptEncoder.matches(rawPassword, encodedPassword);
// DjbSafedbWrapper safedb = DjbSafedbWrapper.getInstance();
// String bcryptHash = safedb.decryptNotRnno(encodedPassword);
// return bcryptEncoder.matches(rawPassword, bcryptHash);
}
}
@@ -0,0 +1,73 @@
package com.eactive.apim.portal.djb.community.qna.comment.controller;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryClosedException;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryCommentNotOwnedException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/djb/inquiry")
@Secured("ROLE_INQUIRY")
@RequiredArgsConstructor
public class InquiryCommentController {
private final InquiryCommentFacade inquiryCommentFacade;
@GetMapping("/{inquiryId}/comments")
public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
return ResponseEntity.ok(inquiryCommentFacade.getComments(inquiryId, current));
}
@PostMapping("/{inquiryId}/comments")
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
@Valid @RequestBody InquiryCommentCreateRequest request) {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(inquiryId, request.getContent(), current);
return ResponseEntity.ok(created);
}
@DeleteMapping("/comments/{commentId}")
public ResponseEntity<Void> delete(@PathVariable String commentId) {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
inquiryCommentFacade.deleteOwnComment(commentId, current);
return ResponseEntity.noContent().build();
}
@ExceptionHandler(InquiryClosedException.class)
public ResponseEntity<Map<String, String>> handleClosed(InquiryClosedException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorBody("INQUIRY_CLOSED", ex.getMessage()));
}
@ExceptionHandler(InquiryCommentNotOwnedException.class)
public ResponseEntity<Map<String, String>> handleNotOwned(InquiryCommentNotOwnedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage()));
}
private Map<String, String> errorBody(String code, String message) {
Map<String, String> body = new HashMap<>();
body.put("code", code);
body.put("message", message);
return body;
}
}
@@ -0,0 +1,14 @@
package com.eactive.apim.portal.djb.community.qna.comment.dto;
import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
public class InquiryCommentCreateRequest {
@NotBlank(message = "댓글 내용을 입력해주세요.")
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
private String content;
}
@@ -0,0 +1,29 @@
package com.eactive.apim.portal.djb.community.qna.comment.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InquiryCommentDTO {
private String id;
private String content;
private String writerId;
private String writerName;
private String adminYn;
private LocalDateTime createdDate;
private boolean deletable;
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.djb.community.qna.comment.event;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageEventHandler;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.template.service.MessageSendEvent;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class InquiryCommentCreatedAdminEvent implements MessageEventHandler {
public static final MessageCode KEY = MessageCode.INQUIRY_COMMENT_CREATED;
@Override
public MessageCode getKey() {
return KEY;
}
@Override
public boolean allowAdditionalRecipients() {
return true;
}
@Override
public String getDisplayName() {
return "Q&A 댓글 등록 알림";
}
@Override
public String getDescription() {
return "<div>사용 가능 변수: </div><br/>"
+ "<div> - inquiryId: 문의 ID </div><br/>"
+ "<div> - inquirySubject: 문의 제목 </div><br/>"
+ "<div> - commentContent: 댓글 본문 </div><br/>"
+ "<div> - writerName: 작성자 이름 </div><br/>";
}
@Override
public MessageSendEvent createEvent(Object source, MessageRecipient recipient, Map<String, Object> params) {
Map<String, String> requestParams = new HashMap<>();
requestParams.put("inquiryId", toStr(params.get("inquiryId")));
requestParams.put("inquirySubject", toStr(params.get("inquirySubject")));
requestParams.put("commentContent", toStr(params.get("commentContent")));
requestParams.put("writerName", toStr(params.get("writerName")));
return new MessageSendEvent(source, KEY, recipient, requestParams);
}
private String toStr(Object value) {
return value == null ? "" : value.toString();
}
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface InquiryCommentRepository extends JpaRepository<InquiryComment, String> {
List<InquiryComment> findByInquiry_IdAndDelYnOrderByCreatedDateAsc(String inquiryId, String delYn);
@Query("select c.inquiry.id, count(c) from InquiryComment c"
+ " where c.inquiry.id in :inquiryIds and c.delYn = :delYn"
+ " group by c.inquiry.id")
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
@Param("delYn") String delYn);
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
@EMSDataSource
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
/**
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
*/
@Query(value = "SELECT * FROM TSEAIRM02 t"
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
nativeQuery = true)
List<UserInfo> findByRoleContaining(@Param("role") String role);
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbAdminRole;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* Q&A 등록/댓글 등록 시 portal-admin 역할(TSEAIRM02)을 가진 관리자 전원에게
* 알림 메시지를 발행한다. 발송 실패는 트랜잭션 롤백을 유발하지 않는다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InquiryAdminNotifier {
private final UserInfoRepository userInfoRepository;
private final MessageHandlerService messageHandlerService;
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
try {
List<UserInfo> admins = userInfoRepository.findByRoleContaining(DjbAdminRole.PORTAL_ADMIN);
if (admins == null || admins.isEmpty()) {
log.warn("portal-admin 역할 관리자가 없습니다 — 알림 미발송 code={}", code.name());
return;
}
for (UserInfo admin : admins) {
try {
MessageRecipient recipient = toRecipient(admin);
messageHandlerService.publishEvent(code, recipient, params);
} catch (Exception e) {
log.warn("portal-admin 개별 알림 발행 실패 — userid={}, code={}",
admin.getUserid(), code.name(), e);
}
}
} catch (Exception e) {
log.warn("portal-admin 알림 발행 실패 — code={}", code.name(), e);
}
}
private MessageRecipient toRecipient(UserInfo admin) {
MessageRecipient r = new MessageRecipient();
r.setUsername(admin.getUsername());
r.setUserId(admin.getEmad());
r.setPhone(admin.getCphnno());
r.setMessengerId(admin.getUserid());
return r;
}
}
@@ -0,0 +1,19 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import java.util.Collection;
import java.util.List;
import java.util.Map;
public interface InquiryCommentFacade {
List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current);
InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current);
void deleteOwnComment(String commentId, PortalAuthenticatedUser current);
Map<String, Long> countActiveByInquiryIds(Collection<String> inquiryIds);
}
@@ -0,0 +1,177 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.apps.community.qna.service.InquiryService;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
private static final String ADMIN_N = "N";
private static final String DEL_N = "N";
private final InquiryService inquiryService;
private final InquiryCommentService commentService;
private final InquiryCommentRepository commentRepository;
private final InquiryCommentPermissionChecker permissionChecker;
private final InquiryAdminNotifier adminNotifier;
private final PortalUserRepository portalUserRepository;
private final UserInfoRepository userInfoRepository;
@Override
@Transactional(readOnly = true)
public List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current) {
inquiryService.getAccessibleInquiry(current, inquiryId);
List<InquiryComment> comments = commentService.findActiveByInquiryId(inquiryId);
Map<String, String> writerNames = resolveWriterNames(comments);
return comments.stream()
.map(c -> toDto(c, current, writerNames))
.collect(Collectors.toList());
}
@Override
public InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current) {
Inquiry inquiry = inquiryService.getAccessibleInquiry(current, inquiryId);
permissionChecker.assertWritable(inquiry);
InquiryComment saved = commentService.create(inquiry, content, ADMIN_N);
publishAdminNotification(inquiry, saved, current);
Map<String, String> writerNames = new HashMap<>();
writerNames.put(current.getId(), current.getUserName());
return toDto(saved, current, writerNames);
}
@Override
@Transactional(readOnly = true)
public Map<String, Long> countActiveByInquiryIds(Collection<String> inquiryIds) {
if (inquiryIds == null || inquiryIds.isEmpty()) {
return Collections.emptyMap();
}
List<Object[]> rows = commentRepository.countActiveGroupByInquiry(inquiryIds, DEL_N);
Map<String, Long> result = new HashMap<>();
for (Object[] row : rows) {
String inquiryId = (String) row[0];
Long count = ((Number) row[1]).longValue();
result.put(inquiryId, count);
}
return result;
}
@Override
public void deleteOwnComment(String commentId, PortalAuthenticatedUser current) {
InquiryComment comment = commentService.findById(commentId)
.orElseThrow(() -> new NotFoundException("댓글을 찾을 수 없습니다. id=" + commentId));
inquiryService.getAccessibleInquiry(current, comment.getInquiry().getId());
permissionChecker.assertDeletable(comment, current);
commentService.softDelete(commentId);
}
private void publishAdminNotification(Inquiry inquiry, InquiryComment comment, PortalAuthenticatedUser current) {
Map<String, Object> params = new HashMap<>();
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("commentContent", comment.getCommentDetail());
params.put("writerName", current.getUserName());
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
}
private Map<String, String> resolveWriterNames(List<InquiryComment> comments) {
Set<String> ids = new HashSet<>();
for (InquiryComment c : comments) {
String createdBy = c.getCreatedBy();
if (createdBy != null && !createdBy.isEmpty()) {
ids.add(createdBy);
}
}
Map<String, String> map = new HashMap<>();
for (String id : ids) {
String name = lookupWriterName(id);
if (name != null && !name.isEmpty()) {
map.put(id, name);
} else {
log.warn("댓글 작성자명 조회 실패 — createdBy={}, isUuid={}", id, isUuid(id));
}
}
return map;
}
private String lookupWriterName(String createdBy) {
if (isUuid(createdBy)) {
String name = portalUserRepository.findById(createdBy)
.map(PortalUser::getUserName).orElse(null);
if (name != null && !name.isEmpty()) {
return name;
}
return userInfoRepository.findById(createdBy)
.map(UserInfo::getUsername).orElse(null);
}
String name = userInfoRepository.findById(createdBy)
.map(UserInfo::getUsername).orElse(null);
if (name != null && !name.isEmpty()) {
return name;
}
return portalUserRepository.findById(createdBy)
.map(PortalUser::getUserName).orElse(null);
}
private boolean isUuid(String value) {
if (value == null || value.length() != 36) {
return false;
}
try {
UUID.fromString(value);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
private InquiryCommentDTO toDto(InquiryComment entity, PortalAuthenticatedUser current,
Map<String, String> writerNames) {
String writerId = entity.getCreatedBy();
boolean deletable = writerId != null
&& writerId.equals(current.getId())
&& entity.getInquiry() != null
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
String writerName = writerId != null ? writerNames.get(writerId) : null;
if (writerName == null || writerName.isEmpty()) {
writerName = "(알 수 없음)";
}
return InquiryCommentDTO.builder()
.id(entity.getId())
.content(entity.getCommentDetail())
.writerId(writerId)
.writerName(writerName)
.adminYn(entity.getAdminYn())
.createdDate(entity.getCreatedDate())
.deletable(deletable)
.build();
}
}
@@ -0,0 +1,46 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
@RequiredArgsConstructor
public class InquiryCommentService {
private static final String DEL_N = "N";
private final InquiryCommentRepository repository;
@Transactional(readOnly = true)
public List<InquiryComment> findActiveByInquiryId(String inquiryId) {
return repository.findByInquiry_IdAndDelYnOrderByCreatedDateAsc(inquiryId, DEL_N);
}
public InquiryComment create(Inquiry inquiry, String content, String adminYn) {
InquiryComment comment = new InquiryComment();
comment.setInquiry(inquiry);
comment.setCommentDetail(content);
comment.setAdminYn(adminYn);
comment.setDelYn(DEL_N);
return repository.save(comment);
}
public Optional<InquiryComment> findById(String commentId) {
return repository.findById(commentId);
}
public void softDelete(String commentId) {
InquiryComment comment = repository.findById(commentId)
.orElseThrow(() -> new NotFoundException("댓글을 찾을 수 없습니다. id=" + commentId));
comment.markDeleted();
}
}
@@ -0,0 +1,10 @@
package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbAdminRole {
/** TSEAIRM02.roleidnfiname 컬럼에서 포털 관리자를 식별하는 값. */
public static final String PORTAL_ADMIN = "portal-admin";
private DjbAdminRole() {
}
}
@@ -0,0 +1,15 @@
package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbInquiryStatus {
public static final String PENDING = "PENDING";
public static final String IN_PROGRESS = "IN_PROGRESS";
public static final String CLOSED = "CLOSED";
private DjbInquiryStatus() {
}
public static boolean isClosed(String status) {
return CLOSED.equals(status);
}
}
@@ -0,0 +1,12 @@
package com.eactive.apim.portal.djb.community.qna.exception;
public class InquiryClosedException extends RuntimeException {
public InquiryClosedException() {
super("종료된 문의에는 댓글을 작성/삭제할 수 없습니다.");
}
public InquiryClosedException(String message) {
super(message);
}
}
@@ -0,0 +1,12 @@
package com.eactive.apim.portal.djb.community.qna.exception;
public class InquiryCommentNotOwnedException extends RuntimeException {
public InquiryCommentNotOwnedException() {
super("본인이 작성한 댓글만 삭제할 수 있습니다.");
}
public InquiryCommentNotOwnedException(String message) {
super(message);
}
}
@@ -0,0 +1,45 @@
package com.eactive.apim.portal.djb.community.qna.support;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryClosedException;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryCommentNotOwnedException;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import org.springframework.stereotype.Component;
@Component
public class InquiryCommentPermissionChecker {
public boolean canWrite(Inquiry inquiry) {
return inquiry != null && !DjbInquiryStatus.isClosed(inquiry.getInquiryStatus());
}
public boolean canDelete(InquiryComment comment, PortalAuthenticatedUser current) {
if (comment == null || current == null) {
return false;
}
boolean owner = comment.getCreatedBy() != null
&& comment.getCreatedBy().equals(current.getId());
boolean notClosed = comment.getInquiry() == null
|| !DjbInquiryStatus.isClosed(comment.getInquiry().getInquiryStatus());
return owner && notClosed;
}
public void assertWritable(Inquiry inquiry) {
if (!canWrite(inquiry)) {
throw new InquiryClosedException();
}
}
public void assertDeletable(InquiryComment comment, PortalAuthenticatedUser current) {
if (comment.getInquiry() != null
&& DjbInquiryStatus.isClosed(comment.getInquiry().getInquiryStatus())) {
throw new InquiryClosedException();
}
if (comment.getCreatedBy() == null
|| !comment.getCreatedBy().equals(current.getId())) {
throw new InquiryCommentNotOwnedException();
}
}
}
+16 -24
View File
@@ -7,7 +7,7 @@ spring:
jpa:
properties:
hibernate:
show_sql: false
show_sql: true
format_sql: true
use_sql_comments: true
devtools:
@@ -21,34 +21,26 @@ spring:
ems:
datasource:
jdbc-url: jdbc:oracle:thin:@//localhost:1599/DAPM
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/PAPM
driver-class-name: oracle.jdbc.OracleDriver
username: EMSAPP
password: elink1234
jndi-name: jdbc/dsOBP_EMS
schema: EMSAPP
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
entity-package: com.eactive.apim.portal
gateway:
datasource:
jdbc-url: jdbc:oracle:thin:@//localhost:1599/DAPM
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/PAPM
driver-class-name: oracle.jdbc.OracleDriver
username: AGWAPP
password: elink1234
jndi-name: jdbc/dsOBP_AGW
schema: AGWAPP
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
entity-package: com.eactive.eai.data.entity.onl
portal:
auth-virtual-code: 654321
test-auth-notice-enabled: true
proxy:
targets:
stg: http://localhost:10000/monitoring
prod: http://localhost:10000/monitoring
# stg: http://inter-ndapiap01.k-bank.com:7090/monitoring
# prod: http://inter-ndapiap01.k-bank.com:7090/monitoring
timeout:
connect: 5000
read: 5000
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
+5 -2
View File
@@ -25,12 +25,15 @@ portal:
test-auth-notice-enabled: true
# 검증 단계에선 사용하지 않음
# auth-virtual-code: 654321
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
ems:
datasource:
jndi-name: jdbc/dsOBP_EMS
schema: EMSADM
schema: EMSAPP
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
@@ -39,7 +42,7 @@ ems:
gateway:
datasource:
jndi-name: jdbc/dsOBP_AGW
schema: AGWADM
schema: AGWAPP
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
+12 -5
View File
@@ -5,7 +5,7 @@ server:
timeout: 15m
encoding:
charset: UTF-8
port: '8080'
port: '39130'
error:
include-message: never
include-stacktrace: never
@@ -38,7 +38,7 @@ spring:
enabled: true
atomikos:
properties:
log-base-dir: /Log/eapim/portal
log-base-dir: /logs/atomikos
log-base-name: adp_tx
thymeleaf:
@@ -59,7 +59,7 @@ sample-code-path: classpath:/templates/sample_code
ems:
datasource:
jndi-name: jdbc/dsOBP_EMS
schema: EMSADM
schema: EMSAPP
driver-class-name: oracle.jdbc.OracleDriver
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
@@ -69,7 +69,7 @@ ems:
gateway:
datasource:
jndi-name: jdbc/dsOBP_AGW
schema: AGWADM
schema: AGWAPP
driver-class-name: oracle.jdbc.OracleDriver
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
@@ -82,7 +82,7 @@ portal:
allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png
logging:
log-path: /Log/App/eapim/
log-path: /logs/eapim
auth-ttl: 300
auth:
@@ -175,6 +175,10 @@ portal:
method: GET
view-name: apps/service/guide
- path-pattern: /service/oauth2-guide
method: GET
view-name: apps/service/oauth2-guide
- path-pattern: /dashboard
method: GET
view-name: apps/mypage/dashboard
@@ -258,6 +262,9 @@ page:
guide:
name: "회원가입 안내"
path: "/service/guide"
oauth2_guide:
name: "OAuth2 개발가이드"
path: "/service/oauth2-guide"
apis:
name: "API"
path: "#"
+1 -1
View File
@@ -1,5 +1,5 @@
<configuration scan="true" scanPeriod="5 seconds">
<property name="LOG_PATH" value="/Log/App/eapim//${inst.Name:-devSvrUD}"/>
<property name="LOG_PATH" value="/logs/eapim/${inst.Name:-devSvrUD}"/>
<property name="PATTERN" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n" />
<property name="PATTERN_DATE" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%-15thread] %-5level %logger - %msg%n" />
-83
View File
@@ -1,83 +0,0 @@
<configuration scan="true" scanPeriod="5 seconds">
<property name="LOG_PATH" value="D:\kjb-logs\${inst.Name:-devSvr00}"/>
<property name="PATTERN" value="%d{HH:mm:ss.SSS} [%thread] [%-3level] %logger - %msg %n" />
<property name="PATTERN_DATE" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%-3level] %logger - %msg %n" />
<property name="PATTERN_CONSOLE" value="%d{HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder><Pattern>${PATTERN}</Pattern></encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/root.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/root.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder><pattern>${PATTERN}</pattern></encoder>
</appender>
<appender name="FILE_HIBERNATE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/hibernate.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/hibernate.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<appender name="HTTP_SESSION" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/http-session.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/http-session.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
<logger name="com.eactive.apim" level="DEBUG" />
<logger name="org.thymeleaf.templateparser" level="DEBUG" />
<!-- Hibernate SQL Logging -->
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.engine.transaction" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.transaction.interceptor" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.orm.jpa" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="com.atomikos" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="eapim.portal.session" level="INFO" additivity="false">
<appender-ref ref="HTTP_SESSION" />
</logger>
</configuration>
-97
View File
@@ -1,97 +0,0 @@
<configuration scan="true" scanPeriod="5 seconds">
<property name="LOG_PATH" value="/eactive/logs/${inst.Name:-devSvr_undefined}"/>
<property name="PATTERN" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n" />
<property name="PATTERN_DATE" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder><pattern>${PATTERN_DATE}</pattern></encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/root.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/root.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder><pattern>${PATTERN}</pattern></encoder>
</appender>
<appender name="FILE_HIBERNATE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/hibernate.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/hibernate.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${PATTERN}</pattern>
</encoder>
</appender>
<appender name="FILE_REPORT" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/report.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/report.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%msg%n</pattern>
</encoder>
</appender>
<appender name="HTTP_SESSION" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/http-session.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/http-session.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="FILE"/>
<appender-ref ref="CONSOLE"/>
</root>
<logger name="com.eactive.eapim" level="DEBUG" />
<!-- hibernate 로그 -->
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.engine.transaction.internal.TransactionImpl" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.Transaction" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.transaction" level="TRACE" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.transaction.interceptor" level="TRACE" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.orm.jpa.JpaTransactionManager" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="com.atomikos" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="com.eactive.apim.portal.spring.DatabaseSessionVerifier" level="debug">
<appender-ref ref="FILE_REPORT" />
</logger>
<logger name="eapim.portal.session" level="INFO" additivity="false">
<appender-ref ref="HTTP_SESSION" />
</logger>
</configuration>
@@ -1,4 +1,4 @@
title.html=KJBank API Portal Portal
title.html=DJBank Partner Portal
password.not.match=Password is not matched
authnumber.not.match=Auth number is not matched.
apim.route.register.file=File
@@ -1,4 +1,4 @@
title.html=\uAD11\uC8FC\uC740\uD589 API Portal
title.html=DJBank \uD30C\uD2B8\uB108\uD3EC\uD0C8
apim.route.register.file=\uCCA8\uBD80\uD30C\uC77C
field.required=\uD544\uC218 \uC785\uB825\uD56D\uBAA9\uC785\uB2C8\uB2E4.
companyName=\uD68C\uC0AC\uBA85
File diff suppressed because it is too large Load Diff
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.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,172 @@
(function () {
'use strict';
const section = document.querySelector('.djb-comment-section');
if (!section) return;
const inquiryId = section.getAttribute('data-inquiry-id');
const closed = section.getAttribute('data-closed') === 'true';
const listEl = document.getElementById('djbCommentList');
const emptyEl = document.getElementById('djbCommentEmpty');
const countEl = document.getElementById('djbCommentCount');
const formEl = document.getElementById('djbCommentForm');
const inputEl = document.getElementById('djbCommentInput');
const counterEl = document.getElementById('djbCommentCharCounter');
function getCsrfToken() {
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function fetchJson(url, options) {
options = options || {};
const headers = Object.assign({}, options.headers || {});
const method = (options.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
headers['X-XSRF-TOKEN'] = getCsrfToken();
}
if (options.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json;charset=UTF-8';
}
return fetch(url, Object.assign({ credentials: 'same-origin' }, options, { headers: headers }));
}
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDate(value) {
if (!value) return '';
try {
const d = new Date(value);
if (isNaN(d.getTime())) return '';
const pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '.' + pad(d.getMonth() + 1) + '.' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
} catch (e) {
return '';
}
}
function render(comments) {
listEl.innerHTML = '';
if (!comments || comments.length === 0) {
const li = document.createElement('li');
li.className = 'djb-comment-empty';
li.textContent = '아직 등록된 댓글이 없습니다.';
listEl.appendChild(li);
return;
}
comments.forEach(function (c) {
const li = document.createElement('li');
li.className = 'djb-comment-item' + (c.adminYn === 'Y' ? ' djb-comment-item--admin' : '');
li.setAttribute('data-comment-id', c.id);
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>' : '')
+ ' </div>'
+ ' <div class="djb-comment-meta-right">'
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
+ (c.deletable ? ' <button type="button" class="djb-comment-delete-btn" data-id="' + escapeHtml(c.id) + '">삭제</button>' : '')
+ ' </div>'
+ '</div>'
+ '<div class="djb-comment-body">' + escapeHtml(c.content) + '</div>';
listEl.appendChild(li);
});
}
function load() {
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments')
.then(function (res) {
if (!res.ok) throw new Error('댓글 조회에 실패했습니다.');
return res.json();
})
.then(render)
.catch(function (err) {
console.error('[djb-comments] load error', err);
});
}
function handleSubmit() {
const content = (inputEl.value || '').trim();
if (!content) {
if (window.customPopups) window.customPopups.showAlert('댓글 내용을 입력해 주세요.');
inputEl.focus();
return;
}
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments', {
method: 'POST',
body: JSON.stringify({ content: content })
})
.then(function (res) {
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
if (!res.ok) throw new Error('댓글 등록에 실패했습니다.');
return res.json();
})
.then(function () {
inputEl.value = '';
updateCounter();
load();
})
.catch(function (err) {
if (window.customPopups) window.customPopups.showAlert(err.message);
else alert(err.message);
});
}
function handleDelete(commentId) {
if (!commentId) return;
function doDelete() {
fetchJson('/djb/inquiry/comments/' + encodeURIComponent(commentId), { method: 'DELETE' })
.then(function (res) {
if (res.status === 403) throw new Error('본인이 작성한 댓글만 삭제할 수 있습니다.');
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 삭제할 수 없습니다.');
if (!res.ok && res.status !== 204) throw new Error('댓글 삭제에 실패했습니다.');
load();
})
.catch(function (err) {
if (window.customPopups) window.customPopups.showAlert(err.message);
else alert(err.message);
});
}
if (window.customPopups) {
window.customPopups.showConfirm('댓글을 삭제하시겠습니까?', function (ok) {
if (ok) doDelete();
});
} else if (window.confirm('댓글을 삭제하시겠습니까?')) {
doDelete();
}
}
function updateCounter() {
if (!counterEl || !inputEl) return;
const max = inputEl.getAttribute('maxlength') || '2000';
counterEl.textContent = (inputEl.value || '').length + ' / ' + max;
}
if (!closed && formEl) {
formEl.addEventListener('submit', function (e) {
e.preventDefault();
handleSubmit();
});
inputEl.addEventListener('input', updateCounter);
updateCounter();
}
listEl.addEventListener('click', function (e) {
const target = e.target;
if (target && target.classList.contains('djb-comment-delete-btn')) {
handleDelete(target.getAttribute('data-id'));
}
});
load();
})();
@@ -35,7 +35,7 @@ $shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15);
$shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
// Typography
$font-family-primary: 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-family-primary: '원신한', 'OneShinhan', 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-family-mono: 'Fira Code', 'Courier New', monospace;
// Font sizes
@@ -24,8 +24,68 @@
//unicode-range: U+AC00-D7AF, U+0000-0040, U+005B-0060, U+007B-007F
}
/* OneShinhan 웹폰트 서빙 (제주은행 inbank/css/font/ 원본을 정적 리소스로 호스팅)
weight 300=Light / 400&500=Medium / 700=Bold */
@font-face {
font-family: 'OneShinhan';
font-weight: 300;
font-style: normal;
src: local('원신한 Light'),
local('OneShinhan Light'),
local('OneShinhan-Light'),
url('/font/djb/OneShinhanLight.woff') format('woff');
}
@font-face {
font-family: 'OneShinhan';
font-weight: 400;
font-style: normal;
src: local('원신한 Medium'),
local('OneShinhan Medium'),
local('OneShinhan-Medium'),
url('/font/djb/OneShinhanMedium.woff') format('woff');
}
@font-face {
font-family: 'OneShinhan';
font-weight: 500;
font-style: normal;
src: local('원신한 Medium'),
local('OneShinhan Medium'),
local('OneShinhan-Medium'),
url('/font/djb/OneShinhanMedium.woff') format('woff');
}
@font-face {
font-family: 'OneShinhan';
font-weight: 700;
font-style: normal;
src: local('원신한 Bold'),
local('OneShinhan Bold'),
local('OneShinhan-Bold'),
url('/font/djb/OneShinhanBold.woff') format('woff');
}
/* 한글 family name alias (변수에서 '원신한' 우선 매칭 시 동일 폰트 사용) */
@font-face {
font-family: '원신한';
font-weight: 300;
font-style: normal;
src: url('/font/djb/OneShinhanLight.woff') format('woff');
}
@font-face {
font-family: '원신한';
font-weight: 400;
font-style: normal;
src: url('/font/djb/OneShinhanMedium.woff') format('woff');
}
@font-face {
font-family: '원신한';
font-weight: 500;
font-style: normal;
src: url('/font/djb/OneShinhanMedium.woff') format('woff');
}
@font-face {
font-family: '원신한';
font-weight: 700;
font-style: normal;
src: url('/font/djb/OneShinhanBold.woff') format('woff');
}
@@ -0,0 +1,256 @@
// -----------------------------------------------------------------------------
// DJBank Custom — Q&A 댓글 영역 (DJPGPT0002)
// 설계서: DJPCSQ0300P 참조
// -----------------------------------------------------------------------------
// 목록 화면 — 제목 옆 댓글 개수 표시 (설계서 ⑥)
.inquiry-comment-count {
display: inline-block;
margin-left: 6px;
color: #DC2626;
font-weight: 600;
font-size: 14px;
}
// 상세 헤더 우측 그룹 (작성자 + 날짜)
.inquiry-header-right {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #6B7280;
.inquiry-detail-writer {
font-weight: 600;
color: #374151;
}
.inquiry-detail-divider {
color: #D1D5DB;
}
.inquiry-detail-date {
font-size: inherit;
color: inherit;
}
}
.djb-comment-section {
margin: 40px 0 48px 0;
}
// ── 댓글 목록 박스 (설계서 ① 영역) ─────────────────────────
.djb-comment-list-box {
padding: 24px 28px;
background-color: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
min-height: 120px;
margin-bottom: 28px;
}
.djb-comment-list {
list-style: none;
padding: 0;
margin: 0;
}
.djb-comment-empty {
padding: 28px 0;
text-align: center;
font-size: 14px;
color: #9CA3AF;
}
// ── 댓글 한 건 (설계서 작성자 + 날짜 + Contents) ───────────
.djb-comment-item {
padding: 18px 0;
border-bottom: 1px dashed #E5E7EB;
&:first-child {
padding-top: 4px;
}
&:last-child {
border-bottom: none;
padding-bottom: 4px;
}
&--admin .djb-comment-writer {
color: #1D4ED8;
}
.djb-comment-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.djb-comment-meta-left {
display: flex;
align-items: center;
gap: 8px;
}
.djb-comment-writer {
font-size: 18px;
font-weight: 700;
color: #111827;
letter-spacing: -0.2px;
}
.djb-comment-admin-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background-color: #DBEAFE;
color: #1D4ED8;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.djb-comment-meta-right {
display: flex;
align-items: center;
gap: 12px;
}
.djb-comment-date {
color: #6B7280;
font-size: 14px;
}
.djb-comment-delete-btn {
background: none;
border: 1px solid #E5E7EB;
border-radius: 4px;
padding: 3px 10px;
font-size: 12px;
color: #6B7280;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background-color: #FEE2E2;
border-color: #FCA5A5;
color: #B91C1C;
}
}
.djb-comment-body {
padding: 12px 16px;
background-color: #F3F4F6;
border-radius: 6px;
font-size: 14px;
line-height: 1.6;
color: #1F2937;
white-space: pre-wrap;
word-break: break-word;
}
}
// ── 작성 영역 (설계서 ②, ③) ───────────────────────────────
.djb-comment-form-wrapper {
display: block;
width: 100%;
margin-top: 8px;
}
.djb-comment-form {
display: block;
width: 100%;
}
textarea.djb-comment-input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 16px 18px;
border: 1px solid #D1D5DB;
border-radius: 8px;
background-color: #F9FAFB;
font-size: 14px;
line-height: 1.6;
color: #1F2937;
resize: vertical;
min-height: 120px;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
&::placeholder {
color: #9CA3AF;
}
&:hover {
border-color: #9CA3AF;
}
&:focus {
border-color: #1D4ED8;
background-color: #FFFFFF;
box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.15);
}
}
.djb-comment-form-actions {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 12px;
}
.djb-comment-char-counter {
font-size: 13px;
color: #94A3B8;
}
button.djb-comment-submit {
display: inline-block;
min-width: 140px;
padding: 12px 28px;
background-color: #1D4ED8;
color: #FFFFFF;
border: 1px solid #1D4ED8;
border-radius: 8px;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.2px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
line-height: 1.2;
text-align: center;
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.18);
transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
&:hover {
background-color: #1E40AF;
border-color: #1E40AF;
box-shadow: 0 3px 10px rgba(29, 78, 216, 0.28);
}
&:active {
transform: translateY(1px);
}
&:disabled {
background-color: #94A3B8;
border-color: #94A3B8;
box-shadow: none;
cursor: not-allowed;
}
}
.djb-comment-readonly-msg {
margin: 0;
padding: 16px 20px;
background-color: #F3F4F6;
border-radius: 6px;
text-align: center;
color: #6B7280;
font-size: 13px;
}
@@ -165,7 +165,7 @@ body.design-survey-active {
align-items: center;
.logo {
height: 22px;
height: 32px;
width: 114px;
}
}
@@ -179,10 +179,10 @@ body.design-survey-active {
// Logo with modern styling
.logo {
display: flex;
align-items: center;
gap: 12px;
height: 22px;
display: block;
height: 32px;
width: 114px;
vertical-align: middle;
z-index: 10;
a {
@@ -194,17 +194,22 @@ body.design-survey-active {
}
img {
height: 22px;
height: 32px;
width: 114px;
display: block;
}
}
.logo-text {
font-size: 22px;
font-weight: 600;
line-height: 32px;
color: var(--text-dark);
text-decoration: none;
display: inline-block;
display: inline-flex;
align-items: center;
height: 32px;
padding-top: 4px;
transition: all 0.3s ease;
// When logo-text is a link
+2 -1
View File
@@ -15,7 +15,7 @@
@use 'base/reset' as *;
@use 'base/typography' as *;
@use 'base/animations' as *;
@use 'base/kjb-font' as *;
@use 'base/djb-font' as *;
// 3. Layout
@use 'layout/header' as *;
@@ -42,6 +42,7 @@
@use 'components/pagination' as *;
@use 'components/breadcrumb' as *;
@use 'components/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *;
// 5. Page-specific styles
@use 'pages/index' as *;
@@ -381,15 +381,16 @@
// Card Grid
.api-card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr); // 3 cards per row on desktop
grid-template-columns: repeat(3, 320px); // 3 cards per row on desktop (고정폭)
gap: $spacing-lg;
justify-content: center;
// Tablet view - 2 cards per row
// Tablet view - 2 cards per row (고정폭)
@media (max-width: $breakpoint-md) {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, 320px);
}
// Mobile view - 1 card per row
// Mobile view - 1 card per row (화면에 맞춤)
@media (max-width: $breakpoint-sm) {
grid-template-columns: 1fr;
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
{
"openapi": "3.0.0",
"info": {
"title": "광주은행 OAuth 2.0 토큰 발급",
"title": "DJBank OAuth 2.0 토큰 발급",
"description": "OAuth 2.0 토큰 발급을 위한 API 문서입니다.",
"version": "1.0.0"
},
@@ -28,7 +28,7 @@
"paths": {
"/api/v1/oauth/token": {
"post": {
"summary": "광주은행 액세스 토큰 발급",
"summary": "DJBank 액세스 토큰 발급",
"description": "client_credentials grant type을 사용하여 액세스 토큰을 발급받습니다.",
"security": [
{
@@ -51,12 +51,12 @@
"client_id": {
"type": "string",
"maxLength": 256,
"description": "광주은행에서 발급한 client id"
"description": "DJBank에서 발급한 client id"
},
"client_secret": {
"type": "string",
"maxLength": 512,
"description": "광주은행에서 발급한 client secret"
"description": "DJBank에서 발급한 client secret"
},
"scope": {
"type": "string",
@@ -1,6 +1,6 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>
@@ -1,6 +1,6 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>
@@ -1,6 +1,6 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>
@@ -1,6 +1,6 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>
@@ -1,6 +1,6 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>
@@ -1,6 +1,6 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>
@@ -1,6 +1,6 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>

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