Merge branch 'jenkins_with_weblogic' into master

This commit is contained in:
curry772
2026-03-11 10:18:57 +09:00
445 changed files with 73668 additions and 30863 deletions
+7 -1
View File
@@ -101,4 +101,10 @@ TODO.txt
*.lck
*.log
.claude
.claude
*.report.md
*.claude.md
diff
*rinjae*
*gf63*
+173
View File
@@ -0,0 +1,173 @@
# eapim-portal 로컬 개발 환경 실행 가이드
> Eclipse Terminal에서 `gradlew bootRun`으로 기동하는 방법
---
## 환경 구성 개요
| 항목 | 내용 |
|------|------|
| DB | 로컬 Docker Oracle 19c |
| 포트 | 8080 |
| 프로파일 | dev |
| 실행 방법 | Eclipse Terminal → `gradlew.bat bootRun` |
---
## 1. 사전 준비: 로그 디렉토리 생성
최초 1회만 실행 (없으면 기동 실패)
```cmd
mkdir C:\Log\eapim\portal
mkdir C:\Log\App\eapim
```
---
## 2. Docker Oracle 기동
```cmd
cd D:\eActive\workspaces\elink-djbank\docker\ekjb
docker-compose up -d
```
Oracle 완전 기동 확인 (처음 기동 시 3~5분 소요):
```cmd
docker logs -f oracle
```
`DATABASE IS READY TO USE!` 메시지 확인 후 다음 단계 진행.
---
## 3. Eclipse Terminal에서 bootRun 실행
### Eclipse Terminal 열기
`Window``Show View``Terminal`
→ 우측 상단 **+** 클릭 → **Local Terminal** 선택
### 실행 명령어
```cmd
cd D:\eActive\workspaces\elink-djbank\eapim-portal
gradlew.bat bootRun
```
### 기동 성공 확인
```
##### PortalApplication Ready #####
```
브라우저에서 `http://localhost:8080` 접속 확인.
---
## 4. 수정된 파일 목록
### 4-1. `src/main/resources/application-dev.yml`
DB 호스트를 원격 서버에서 로컬 Docker로 변경:
```yaml
ems:
datasource:
jdbc-url: jdbc:oracle:thin:@//localhost:1599/DAPM # 변경
username: EMSAPP
password: elink1234
gateway:
datasource:
jdbc-url: jdbc:oracle:thin:@//localhost:1599/DAPM # 변경
username: AGWAPP
password: elink1234
```
### 4-2. `build.gradle`
`bootRun` 태스크에 `sourceResources` 추가:
```groovy
bootRun {
sourceResources sourceSets.main // processResources 필터 우회 (dev yml 직접 참조)
args = ["--spring.profiles.active=dev"]
}
```
> **이유**: `processResources`가 `application-dev.yml`을 빌드 출력에서 제외하도록 설정되어 있어,
> `sourceResources sourceSets.main`을 추가해야 `bootRun` 실행 시 소스 디렉토리를 직접 참조함.
### 4-3. `src/main/resources/logback-spring.xml`
`dev` 프로파일에 CONSOLE appender 추가 (콘솔 로그 출력):
```xml
<springProfile name="dev">
<root level="DEBUG">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/> <!-- 추가 -->
</root>
</springProfile>
```
### 4-4. `.classpath`
MapStruct 등 Gradle annotation processor 생성 소스를 Eclipse 소스 폴더로 추가:
```xml
<classpathentry output="bin/main" kind="src"
path="build/generated/sources/annotationProcessor/java/main">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
```
> **이유**: Eclipse는 Gradle annotation processing을 자동 실행하지 않으므로,
> `gradlew.bat compileJava` 실행 후 생성된 소스 경로를 Eclipse 빌드 경로에 수동 추가 필요.
---
## 5. Annotation Processor 소스 재생성
소스 구조 변경(매퍼 추가 등) 시 재실행 필요:
```cmd
cd D:\eActive\workspaces\elink-djbank\eapim-portal
gradlew.bat compileJava
```
이후 Eclipse에서 `Project``Clean` 실행.
---
## 6. Eclipse에서 직접 기동이 안 되는 이유
Eclipse는 참조 프로젝트(`elink-online-core-jpa`)의 test scope 의존성을 구분하지 않고
런타임 클래스패스에 모두 포함시킵니다.
이로 인해 Spring Boot 버전 충돌 발생:
| 출처 | jar | 충돌 대상 |
|------|-----|-----------|
| `elink-online-core-jpa` (test) | `spring-boot-autoconfigure:2.6.15` | eapim-portal `2.7.18` |
| `elink-online-core-jpa` (test) | `spring-core:5.3.27` | eapim-portal `5.3.31` |
`gradlew bootRun`은 Gradle의 dependency management(BOM)로 버전을 통일하므로 이 문제가 없음.
---
## 7. Docker Oracle 계정 정보
| 항목 | 값 |
|------|----|
| Host | localhost |
| Port | 1599 |
| SID | DAPM |
| EMS 계정 | EMSAPP / elink1234 |
| GW 계정 | AGWAPP / elink1234 |
+41 -1
View File
@@ -14,6 +14,46 @@
- Spring Security (커스텀 인증)
- JPA/Hibernate 5.6.15 with Envers (감사 추적)
- MapStruct (DTO 매핑), Lombok (보일러플레이트 제거)
- SASS/SCSS (CSS 전처리기) - 스타일 수정 시 반드시 SASS 파일 수정
**CSS/SASS 가이드:**
- CSS 파일(`src/main/resources/static/css/main.css`)은 SASS에서 컴파일된 결과물
- 스타일 수정 시 반드시 SASS 파일(`src/main/resources/static/sass/`)을 수정해야 함
- CSS 파일 직접 수정 금지 - SASS 파일 수정 후 컴파일 필요
- SASS 컴파일: `sass src/main/resources/static/sass/main.scss src/main/resources/static/css/main.css`
## Git 브랜치 전략
**저장소 정보:**
- 기본 저장소: `ssh://git@192.168.240.178:18081/eapim/eapim-portal.git`
- 대체 저장소: `https://git.eactive.synology.me:8090/kjb-eapim/eapim-portal.git`
**브랜치:**
- **jenkins_with_weblogic**: Jenkins 빌드와 WebLogic 테스트를 위한 브랜치 (기본 개발 브랜치)
- Jenkins CI/CD 파이프라인 설정 포함
- WebLogic 배포 및 테스트 환경 설정
- 일상적인 개발 작업은 이 브랜치에서 수행
- 기능 개발, 버그 수정, 테스트 등 모든 개발 활동의 기본 브랜치
- **master**: 완벽히 동작하는 검증된 코드 저장소 (안정 브랜치)
- 프로덕션 배포 가능한 안정적인 코드만 포함
- jenkins_with_weblogic 브랜치에서 충분히 테스트된 코드만 병합
- 직접 커밋 금지, Pull Request를 통한 병합만 허용
**브랜치 사용 가이드:**
```bash
# 개발 시작 시 jenkins_with_weblogic 브랜치에서 작업
git checkout jenkins_with_weblogic
# 기능 개발 후 커밋
git add .
git commit -m "기능 설명"
# jenkins_with_weblogic 브랜치에 푸시
git push origin jenkins_with_weblogic
# 충분한 테스트 완료 후 master로 병합 (Pull Request 사용)
```
## 빌드 명령어
@@ -433,7 +473,7 @@ gradle bootWar
./build_docker.sh
# 컨테이너 실행
docker run -p 8080:8080 \
docker run -p 30200:30200 \
-e JAVA_OPTS="-Dspring.profiles.active=prod" \
-v /Log:/Log \
eapim-portal:latest
+438 -14
View File
@@ -2,6 +2,55 @@
광주은행 엔터프라이즈 API 포털 관리 시스템
## 프로젝트 클론 (Git Clone)
아래 스크립트를 복사하여 한 번에 실행하면 모든 프로젝트를 클론합니다:
```bash
# 변수 설정 (환경에 맞게 수정하세요)
GIT_URL="ssh://git@192.168.240.178:18081/eapim"
PROJECT_HOME="/c/eactive/workspaces/kjb-eapim-devPortal"
DEFAULT_BRANCH="jenkins_with_weblogic"
# 프로젝트 홈 디렉토리 생성 및 이동
mkdir -p "$PROJECT_HOME"
cd "$PROJECT_HOME"
# 메인 프로젝트 클론
git clone -b "$DEFAULT_BRANCH" "$GIT_URL/kjb-eapim.git" kjb-eapim
# 의존 모듈 클론 (kjb-eapim 폴더와 동일 레벨)
mkdir -p eapim-online
git clone -b "$DEFAULT_BRANCH" "$GIT_URL/elink-online-core-jpa.git" eapim-online/elink-online-core-jpa
git clone -b "$DEFAULT_BRANCH" "$GIT_URL/elink-portal-common.git" elink-portal-common
git clone -b "$DEFAULT_BRANCH" "$GIT_URL/kjb-safedb.git" kjb-safedb
# 클론 후 폴더 구조 확인
echo "=== 프로젝트 구조 ==="
ls -la "$PROJECT_HOME"
```
### 클론 후 폴더 구조
```
$PROJECT_HOME/
├── kjb-eapim/ # 메인 프로젝트
│ └── eapim-portal/ # 이 프로젝트 (Portal)
├── eapim-online/ # Gateway 데이터 모델
│ └── elink-online-core-jpa/ # JPA 엔티티 모듈
├── elink-portal-common/ # 공통 유틸리티
└── kjb-safedb/ # SafeDB 암호화 라이브러리
```
### 의존 관계 (settings.gradle 참조)
```
eapim-portal
├── elink-online-core-jpa → ../eapim-online/elink-online-core-jpa
├── elink-portal-common → ../elink-portal-common
└── kjb-safedb → ../kjb-safedb
```
## 프로젝트 개요
**EAPIM Portal**은 광주은행의 API 서비스를 관리하고, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
@@ -16,23 +65,255 @@
- JPA/Hibernate 5.6.15 with Envers
- MapStruct, Lombok
## 초기 세팅 가이드
### 사전 요구사항
- JDK 8 이상
- Gradle 8.7
- Git
- Oracle 19c (또는 개발 환경에 따라 접근 가능한 DB)
### Git Bash에서 초기 세팅
```bash
# ====================================
# 1. 변수 설정 (프로젝트 경로 및 브랜치)
# ====================================
export WORKSPACE_DIR="/c/eactive/workspaces/kjb-eapim"
export PROJECT_BRANCH="jenkins_with_weblogic"
# Git Repository URL 설정
# 기본: ssh://git@192.168.240.178:18081/eapim
# 대체: https://git.eactive.synology.me:8090/kjb-eapim
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. 작업 디렉토리 생성
# ====================================
mkdir -p $WORKSPACE_DIR
cd $WORKSPACE_DIR
# ====================================
# 3. eapim-portal 프로젝트 클론
# ====================================
git clone ${GIT_REPO_BASE}/eapim-portal.git
cd eapim-portal
# 프로젝트 브랜치로 체크아웃
git checkout $PROJECT_BRANCH
# ====================================
# 4. eapim-online 프로젝트 클론 (elink-online-core-jpa 포함)
# ====================================
cd $WORKSPACE_DIR
git clone ${GIT_REPO_BASE}/eapim-online.git
cd eapim-online
git checkout $PROJECT_BRANCH
# ====================================
# 5. 의존 모듈 클론
# ====================================
cd $WORKSPACE_DIR
# elink-portal-common 클론
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. 최종 폴더 구조 확인
# ====================================
# kjb-eapim/
# ├── eapim-portal/
# ├── eapim-online/
# │ └── elink-online-core-jpa/
# ├── elink-portal-common/
# └── kjb-safedb/
# ====================================
# 7. eapim-portal 프로젝트로 이동 및 디펜던시 다운로드
# ====================================
cd $WORKSPACE_DIR/eapim-portal
# Gradle 디펜던시 다운로드
$GRADLE_CMD dependencies
```
### Windows CMD에서 초기 세팅
```cmd
REM ====================================
REM 1. 변수 설정 (프로젝트 경로 및 브랜치)
REM ====================================
set WORKSPACE_DIR=C:\eactive\workspaces\kjb-eapim
set PROJECT_BRANCH=jenkins_with_weblogic
REM Git Repository URL 설정
REM 기본: ssh://git@192.168.240.178:18081/eapim
REM 대체: https://git.eactive.synology.me:8090/kjb-eapim
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. 작업 디렉토리 생성
REM ====================================
if not exist "%WORKSPACE_DIR%" mkdir "%WORKSPACE_DIR%"
cd /d "%WORKSPACE_DIR%"
REM ====================================
REM 3. eapim-portal 프로젝트 클론
REM ====================================
git clone %GIT_REPO_BASE%/eapim-portal.git
cd eapim-portal
REM 프로젝트 브랜치로 체크아웃
git checkout %PROJECT_BRANCH%
REM ====================================
REM 4. eapim-online 프로젝트 클론 (elink-online-core-jpa 포함)
REM ====================================
cd /d "%WORKSPACE_DIR%"
git clone %GIT_REPO_BASE%/eapim-online.git
cd eapim-online
git checkout %PROJECT_BRANCH%
REM ====================================
REM 5. 의존 모듈 클론
REM ====================================
cd /d "%WORKSPACE_DIR%"
REM elink-portal-common 클론
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 ====================================
REM kjb-eapim\
REM ├── eapim-portal\
REM ├── eapim-online\
REM │ └── elink-online-core-jpa\
REM ├── elink-portal-common\
REM └── kjb-safedb\
REM ====================================
REM 7. eapim-portal 프로젝트로 이동 및 디펜던시 다운로드
REM ====================================
cd /d "%WORKSPACE_DIR%\eapim-portal"
REM Gradle 디펜던시 다운로드
%GRADLE_CMD% dependencies
```
### 환경별 설정 파일
프로젝트 클론 후 필요한 환경 설정을 수정하세요:
- `src/main/resources/application-dev.yml` - 개발 환경
- `src/main/resources/application-stage.yml` - 스테이징 환경
- `src/main/resources/application-prod.yml` - 운영 환경
- `src/main/resources/application-gf63.yml` - 개인 개발 환경
### 데이터베이스 설정
개발 환경(dev)에서는 `application-dev.yml`에 데이터베이스 설정이 포함되어 있습니다.
스테이징/운영 환경에서는 JNDI 데이터소스가 필요합니다:
- `jdbc/dsOBP_EMS` - Portal 데이터베이스
- `jdbc/dsOBP_AGW` - Gateway 데이터베이스
### 주의사항
- **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
```
### 빌드 및 실행 명령어
```bash
# 개발 환경 실행
gradle bootRun
$GRADLE_CMD bootRun
# 또는: %GRADLE_CMD% bootRun (CMD)
# WAR 빌드
gradle bootWar
$GRADLE_CMD bootWar
# 테스트
gradle test
$GRADLE_CMD test
# 컴파일만 수행
$GRADLE_CMD classes
# 전체 빌드
$GRADLE_CMD clean build
```
**주의**: gradlew 사용 금지 - offline gradle 단독 실행
## 프로젝트 구조
### 전체 프로젝트 구조
```
kjb-eapim/
├── eapim-portal/ # Portal 웹 애플리케이션 (현재 프로젝트)
├── eapim-online/ # Online 프로젝트
│ └── elink-online-core-jpa/ # Gateway 데이터 모델
├── elink-portal-common/ # 공통 유틸리티
└── kjb-safedb/ # SafeDB 암호화 라이브러리
```
### eapim-portal 내부 구조
```
src/main/java/com/eactive/apim/portal/
├── apps/ # 기능 모듈 (user, app, approval, apis, etc.)
@@ -43,10 +324,153 @@ src/main/java/com/eactive/apim/portal/
## 외부 모듈
이 프로젝트는 다음 모듈에 의존합니다:
- `elink-online-core-jpa`: Gateway 데이터 모델
- `elink-portal-common`: 공통 유틸리티
- `kjb-safedb`: SafeDB 암호화 라이브러리
이 프로젝트는 다음 모듈에 의존합니다 (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 스타일 개발
### 개요
이 프로젝트는 **Dart Sass**를 사용하여 CSS를 관리합니다.
`src/main/resources/static/css/main.css`는 SASS에서 컴파일된 결과물이므로 **직접 수정하면 안 됩니다.**
스타일 변경은 반드시 `src/main/resources/static/sass/` 하위 파일을 수정하세요.
### 설치
```bash
# Node.js가 설치된 상태에서
npm install
```
### 빌드 명령어
```bash
# 1회 컴파일 (개발용, 들여쓰기 유지)
npm run sass:build
# 1회 컴파일 (배포용, minified)
npm run sass:build:minified
# 파일 변경 감지 후 자동 컴파일 (개발 중 권장)
npm run sass:watch
# 빌드 + minified 동시 생성
npm run build
# 단축 스크립트 (npm 없이 sass 직접 실행)
./sass-build.sh
```
### SASS 파일 구조
```
src/main/resources/static/sass/
├── main.scss # 진입점 - 모든 파일을 @use로 조합
├── abstracts/ # CSS 출력 없는 헬퍼 (변수, 믹스인 등)
│ ├── _variables.scss # 색상, 타이포그래피, 그림자 변수
│ ├── _mixins.scss # 재사용 믹스인
│ └── _color-functions.scss # Dart Sass 3.0 color 유틸리티
├── base/ # 전역 기본 스타일
│ ├── _reset.scss
│ ├── _typography.scss
│ ├── _animations.scss
│ ├── _kjb-font.scss # 광주은행 전용 폰트
│ └── _utilities.scss # 유틸리티 클래스
├── layout/ # 페이지 레이아웃 구조
│ ├── _header.scss
│ ├── _footer.scss
│ ├── _grid.scss
│ └── _container.scss
├── components/ # 재사용 UI 컴포넌트
│ ├── _buttons.scss
│ ├── _forms.scss
│ ├── _modals.scss # customPopup 모달 스타일
│ ├── _tables.scss
│ ├── _badges.scss
│ ├── _cards.scss
│ ├── _navigation.scss
│ ├── _pagination.scss
│ └── ...
├── pages/ # 페이지별 스타일
│ ├── _mypage.scss
│ ├── _login.scss
│ ├── _apikey-list.scss
│ └── ...
├── themes/
│ └── _dark.scss # 다크 테마 (예약)
└── vendors/
└── _overrides.scss # 외부 라이브러리 스타일 덮어쓰기
```
### 새 스타일 추가하는 방법
#### 1. 기존 컴포넌트/페이지 파일에 추가
해당 `_*.scss` 파일을 직접 수정합니다.
```scss
// components/_buttons.scss 예시
.btn-withdrawal {
color: $text-gray;
border: 1px solid $border-gray;
&:hover {
color: $accent-orange;
}
}
```
#### 2. 새 페이지 스타일 파일 추가
```bash
# 1. 파일 생성 (언더스코어 접두사 필수)
touch src/main/resources/static/sass/pages/_new-page.scss
# 2. main.scss 에 @use 추가
```
```scss
// main.scss 에 추가
@use 'pages/new-page' as *;
```
#### 3. 변수 활용 예시
```scss
// abstracts/_variables.scss 에 정의된 변수 사용
.my-component {
color: $primary-blue; // #0049b4 (광주은행 블루)
background: $gray-bg; // #F8FAFC
border: 1px solid $border-gray; // #E2E8F0
box-shadow: $shadow-md;
font-family: $font-family-primary;
}
```
### 컴파일 결과 확인
```bash
# 컴파일 후 출력 파일 확인
ls -lh src/main/resources/static/css/main.css
ls -lh src/main/resources/static/css/main.min.css # minified
```
### 주의사항
- `src/main/resources/static/css/main.css` **직접 수정 금지** — SASS 재컴파일 시 덮어씌워짐
- `style2.css` 는 레거시 파일(구 디자인 시스템)이며 `main.css`**별도로 관리됨**
- `@import` 대신 **`@use`** 사용 (Dart Sass 3.0 호환)
- 파일명 앞에 언더스코어(`_`) 필수 — `_filename.scss` 형식이어야 단독 컴파일되지 않음
---
## AI 코딩 어시스턴트 지침 파일
@@ -127,16 +551,16 @@ docker run -p 8080:8080 eapim-portal:latest
## 관련 프로젝트
- `../eapim-admin/` - Admin 포털
- `../eapim-online/` - Online 포털
- `../kjb-eapim-sql/` - SQL 스크립트
프로젝트 구조 (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 스크립트 (선택사항)
## 문서
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
## 라이선스
© 광주은행 (Kwangju Bank)
+40 -1
View File
@@ -14,6 +14,14 @@ targetCompatibility = "1.8"
def nexusUrl = "https://nexus.eactive.synology.me:8090"
allprojects {
repositories {
maven {
url "${nexusUrl}/repository/maven-public/"
allowInsecureProtocol = true
}
}
}
dependencies {
annotationProcessor "org.projectlombok:lombok:1.18.28"
@@ -27,7 +35,7 @@ dependencies {
implementation project(':elink-online-core-jpa')
implementation project(':elink-portal-common')
implementation project(':kjb-safedb')
// implementation project(':kjb-safedb')
implementation('org.springframework.boot:spring-boot-starter')
implementation 'org.springframework.boot:spring-boot-starter-jta-atomikos'
@@ -79,6 +87,11 @@ dependencies {
implementation 'net.bytebuddy:byte-buddy:1.14.5'
// Commons FileUpload (WAS 독립적인 multipart 처리)
implementation 'commons-fileupload:commons-fileupload:1.5'
implementation 'commons-io:commons-io:2.15.1'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0'
testImplementation 'org.mockito:mockito-inline:3.11.2'
@@ -110,6 +123,7 @@ bootRun {
// 디버깅 사용시에는 아래 내용 주석 해제 하세요
// jvmArgs '-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005'
sourceResources sourceSets.main // processResources 필터 우회 (dev yml 직접 참조)
args = ["--spring.profiles.active=dev"]
}
@@ -132,6 +146,15 @@ compileJava {
options.compilerArgs += ["-parameters"]
options.encoding = 'UTF-8'
}
processResources {
exclude { details ->
details.file.name.startsWith('application-') &&
details.file.name.endsWith('.yml') &&
!(details.file.name in ['application-stage.yml', 'application-prod.yml'])
}
}
test {
useJUnitPlatform()
enabled = true
@@ -158,4 +181,20 @@ 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+')
}
}
+260
View File
@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>광주은행 API Portal</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style type="text/css">
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: 100% !important;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* Custom styles */
.email-wrapper {
width: 100%;
background-color: #c3dfea;
}
.email-container {
max-width: 700px;
margin: 0 auto;
background-color: #c3dfea;
}
.header-section {
padding: 28px 25px;
}
.content-section {
background-color: #fdfdfd;
margin: 0 25px;
border-top: 4px solid #0049b4;
min-height: 400px;
}
.content-inner {
padding: 40px 30px;
}
.footer-section {
padding: 25px;
}
.footer-notice {
font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 12px;
color: #515151;
line-height: 20px;
}
.footer-links {
font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 12px;
color: #515151;
}
.footer-links a {
color: #515151;
text-decoration: none;
}
.footer-links a:hover {
text-decoration: underline;
}
.footer-info {
font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 12px;
color: #515151;
}
.footer-copyright {
font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 12px;
color: #8c959f;
}
.divider {
color: #515151;
padding: 0 7px;
}
.logo-text {
font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 16px;
font-weight: 700;
color: #212529;
vertical-align: middle;
padding-left: 8px;
}
/* Responsive styles */
@media screen and (max-width: 700px) {
.email-container {
width: 100% !important;
}
.content-section {
margin: 0 15px !important;
}
.content-inner {
padding: 30px 20px !important;
}
.header-section,
.footer-section {
padding: 20px 15px !important;
}
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #c3dfea;">
<!-- Email Wrapper -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #c3dfea;">
<tr>
<td align="center" style="padding: 0;">
<!-- Email Container -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="700" class="email-container" style="max-width: 700px; background-color: #c3dfea;">
<!-- Header Section -->
<tr>
<td class="header-section" style="padding: 28px 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="vertical-align: middle;">
<!-- Logo Image - Replace src with actual logo URL -->
<img src="email_logo_top.png" alt="광주은행" width="118" height="22" style="display: inline-block; vertical-align: middle;">
<span class="logo-text" style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 16px; font-weight: 700; color: #212529; vertical-align: middle; padding-left: 8px;">API Portal</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Content Section -->
<tr>
<td style="padding: 0 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fdfdfd; border-top: 4px solid #0049b4;">
<tr>
<td class="content-inner" style="padding: 40px 30px; min-height: 400px;">
<!-- ====================== -->
<!-- EMAIL CONTENT START -->
<!-- ====================== -->
<!-- 이 영역에 실제 이메일 내용을 작성하세요 -->
<!-- Example content structure: -->
<!--
<h1 style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 24px; color: #212529; margin: 0 0 20px 0;">
이메일 제목
</h1>
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 14px; color: #515151; line-height: 1.6; margin: 0 0 15px 0;">
이메일 본문 내용이 들어갑니다.
</p>
-->
<!-- ====================== -->
<!-- EMAIL CONTENT END -->
<!-- ====================== -->
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Section -->
<tr>
<td class="footer-section" style="padding: 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Footer Logo and Notice -->
<tr>
<td style="padding-bottom: 10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="96" style="vertical-align: top; padding-right: 25px;">
<!-- Footer Logo - Replace src with actual logo URL -->
<img src="email_logo_bottom.png" alt="광주은행" width="96" height="18" style="display: block;">
</td>
<td style="vertical-align: top;">
<p class="footer-notice" style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151; line-height: 20px; margin: 0;">
본 메일은 발신 전용으로 회신되지 않습니다.<br>
관련 문의사항은 고객센터 (1588-3388) 또는 홈페이지를 이용하시기 바랍니다.
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Links -->
<tr>
<td style="padding: 10px 0 0 121px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="footer-links" style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151;">
<a href="https://www.kjbank.com" style="color: #515151; text-decoration: none;">은행소개</a>
<span class="divider" style="color: #515151; padding: 0 7px;">|</span>
<a href="https://www.kjbank.com/privacy" style="color: #515151; text-decoration: none;">개인정보처리방침</a>
<span class="divider" style="color: #515151; padding: 0 7px;">|</span>
<a href="https://www.kjbank.com/faq" style="color: #515151; text-decoration: none;">자주 묻는 질문</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Info -->
<tr>
<td style="padding: 8px 0 0 121px;">
<p class="footer-info" style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151; margin: 0;">
광주광역시 동구 제봉로225, 1층(대인동)
<span class="divider" style="color: #515151; padding: 0 7px;">|</span>
고객센터 : 1588-3388
<span class="divider" style="color: #515151; padding: 0 7px;">|</span>
사업자 등록번호 : ???-??-?????
</p>
</td>
</tr>
<!-- Copyright -->
<tr>
<td style="padding: 12px 0 0 121px;">
<p class="footer-copyright" style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #8c959f; margin: 0;">
Copyright KWANGJU Bank. ALL Rights Reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- End Email Container -->
</td>
</tr>
</table>
<!-- End Email Wrapper -->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+255
View File
@@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>일회용 비밀번호 - 광주은행 API Portal</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style type="text/css">
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: 100% !important;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* Responsive styles */
@media screen and (max-width: 700px) {
.email-container {
width: 100% !important;
}
.content-section {
margin: 0 15px !important;
}
.content-inner {
padding: 30px 20px !important;
}
.header-section,
.footer-section {
padding: 20px 15px !important;
}
.password-box {
width: 90% !important;
}
.login-button {
width: 80% !important;
}
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #ececec;">
<!-- Email Wrapper -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #ececec;">
<tr>
<td align="center" style="padding: 0;">
<!-- Email Container -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="700" class="email-container" style="max-width: 700px; background-color: #ececec;">
<!-- Header Section -->
<tr>
<td class="header-section" style="padding: 14px 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="vertical-align: middle;">
<!-- Logo Image -->
<img src="email_logo_top.png" alt="광주은행" width="118" height="22" style="display: inline-block; vertical-align: middle;">
<span style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 16px; font-weight: 700; color: #212529; vertical-align: middle; padding-left: 8px;">API Portal</span>
</td>
<td style="vertical-align: middle; text-align: right;">
<!-- Key Icon -->
<img src="email_icon_key.png" alt="비밀번호" width="74" height="60" style="display: inline-block; vertical-align: middle;">
</td>
</tr>
</table>
</td>
</tr>
<!-- Content Section -->
<tr>
<td style="padding: 0 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fdfdfd; border-top: 4px solid #0049b4;">
<tr>
<td class="content-inner" style="padding: 40px 30px;">
<!-- Title -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 30px;">
<h1 style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 28px; font-weight: 700; color: #2f3b51; margin: 0;">
[일회용 비밀번호 ]
</h1>
</td>
</tr>
</table>
<!-- Greeting Message -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 30px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 20px; color: #000000; line-height: 36px; margin: 0; text-align: center;">
<span style="font-weight: 700;">{{userName}}님</span> 안녕하세요.<br>
광주은행 API Portal 비밀번호가 초기화 되어 일회용<br>
비밀번호를 발급해 드립니다.
</p>
</td>
</tr>
</table>
<!-- One-Time Password Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" class="password-box" style="width: 438px; max-width: 100%;">
<tr>
<td align="center" style="background-color: #ffffff; border: 6px solid #e5e7eb; border-radius: 20px; padding: 20px 30px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 24px; font-weight: 700; color: #0049b4; margin: 0; letter-spacing: 2px;">
{{otpPassword}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Login Guide Text -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 25px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 20px; font-weight: 700; color: #212529; margin: 0;">
아래 버튼을 클릭하여 로그인을 진행해 주세요.
</p>
</td>
</tr>
</table>
<!-- Login Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" class="login-button" style="background-color: #0049b4; border-radius: 12px; width: 220px;">
<a href="{{loginUrl}}" target="_blank" style="display: block; padding: 18px 40px; font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 18px; font-weight: 700; color: #ffffff; text-decoration: none; text-align: center;">
로그인
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Section -->
<tr>
<td class="footer-section" style="padding: 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Footer Logo and Notice -->
<tr>
<td style="padding-bottom: 10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="96" style="vertical-align: top; padding-right: 25px;">
<!-- Footer Logo -->
<img src="email_logo_bottom.png" alt="광주은행" width="96" height="18" style="display: block;">
</td>
<td style="vertical-align: top;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151; line-height: 20px; margin: 0;">
본 메일은 발신 전용으로 회신되지 않습니다.<br>
관련 문의사항은 고객센터 (1588-3388) 또는 홈페이지를 이용하시기 바랍니다.
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Links -->
<tr>
<td style="padding: 10px 0 0 121px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151;">
<a href="https://www.kjbank.com" style="color: #515151; text-decoration: none;">은행소개</a>
<span style="color: #515151; padding: 0 7px;">|</span>
<a href="https://www.kjbank.com/privacy" style="color: #515151; text-decoration: none;">개인정보처리방침</a>
<span style="color: #515151; padding: 0 7px;">|</span>
<a href="https://www.kjbank.com/faq" style="color: #515151; text-decoration: none;">자주 묻는 질문</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Info -->
<tr>
<td style="padding: 8px 0 0 121px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151; margin: 0;">
광주광역시 동구 제봉로225, 1층(대인동)
<span style="color: #515151; padding: 0 7px;">|</span>
고객센터 : 1588-3388
<span style="color: #515151; padding: 0 7px;">|</span>
사업자 등록번호 : ???-??-?????
</p>
</td>
</tr>
<!-- Copyright -->
<tr>
<td style="padding: 12px 0 0 121px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #8c959f; margin: 0;">
Copyright KWANGJU Bank. ALL Rights Reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- End Email Container -->
</td>
</tr>
</table>
<!-- End Email Wrapper -->
</body>
</html>
+234
View File
@@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>비밀번호 변경 완료 안내 - 광주은행 API Portal</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style type="text/css">
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: 100% !important;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* Responsive styles */
@media screen and (max-width: 700px) {
.email-container {
width: 100% !important;
}
.content-section {
margin: 0 15px !important;
}
.content-inner {
padding: 30px 20px !important;
}
.header-section,
.footer-section {
padding: 20px 15px !important;
}
.login-button {
width: 80% !important;
}
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #ececec;">
<!-- Email Wrapper -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #ececec;">
<tr>
<td align="center" style="padding: 0;">
<!-- Email Container -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="700" class="email-container" style="max-width: 700px; background-color: #ececec;">
<!-- Header Section -->
<tr>
<td class="header-section" style="padding: 14px 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="vertical-align: middle;">
<!-- Logo Image -->
<img src="email_logo_top.png" alt="광주은행" width="118" height="22" style="display: inline-block; vertical-align: middle;">
<span style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 16px; font-weight: 700; color: #212529; vertical-align: middle; padding-left: 8px;">API Portal</span>
</td>
<td style="vertical-align: middle; text-align: right;">
<!-- Check/Shield Icon -->
<img src="email_icon_check.png" alt="완료" width="64" height="64" style="display: inline-block; vertical-align: middle;">
</td>
</tr>
</table>
</td>
</tr>
<!-- Content Section -->
<tr>
<td style="padding: 0 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fdfdfd; border-top: 4px solid #0049b4;">
<tr>
<td class="content-inner" style="padding: 50px 30px;">
<!-- Title -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 40px;">
<h1 style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 28px; font-weight: 700; color: #2f3b51; margin: 0;">
[ 비밀번호 변경 완료 안내 ]
</h1>
</td>
</tr>
</table>
<!-- Greeting Message -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 50px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 20px; color: #000000; line-height: 36px; margin: 0; text-align: center;">
<span style="font-weight: 700;">{{userName}}님</span> 안녕하세요.<br>
광주은행 API Portal 비밀번호가 변경되어 안내 드립니다.
</p>
</td>
</tr>
</table>
<!-- Login Guide Text -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 25px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 20px; font-weight: 700; color: #212529; margin: 0;">
아래 버튼을 클릭하여 로그인을 진행해 주세요.
</p>
</td>
</tr>
</table>
<!-- Login Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" class="login-button" style="background-color: #0049b4; border-radius: 12px; width: 220px;">
<a href="{{loginUrl}}" target="_blank" style="display: block; padding: 18px 40px; font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 18px; font-weight: 700; color: #ffffff; text-decoration: none; text-align: center;">
로그인
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Section -->
<tr>
<td class="footer-section" style="padding: 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Footer Logo and Notice -->
<tr>
<td style="padding-bottom: 10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="96" style="vertical-align: top; padding-right: 25px;">
<!-- Footer Logo -->
<img src="email_logo_bottom.png" alt="광주은행" width="96" height="18" style="display: block;">
</td>
<td style="vertical-align: top;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151; line-height: 20px; margin: 0;">
본 메일은 발신 전용으로 회신되지 않습니다.<br>
관련 문의사항은 고객센터 (1588-3388) 또는 홈페이지를 이용하시기 바랍니다.
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Links -->
<tr>
<td style="padding: 10px 0 0 121px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151;">
<a href="https://www.kjbank.com" style="color: #515151; text-decoration: none;">은행소개</a>
<span style="color: #515151; padding: 0 7px;">|</span>
<a href="https://www.kjbank.com/privacy" style="color: #515151; text-decoration: none;">개인정보처리방침</a>
<span style="color: #515151; padding: 0 7px;">|</span>
<a href="https://www.kjbank.com/faq" style="color: #515151; text-decoration: none;">자주 묻는 질문</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Info -->
<tr>
<td style="padding: 8px 0 0 121px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151; margin: 0;">
광주광역시 동구 제봉로225, 1층(대인동)
<span style="color: #515151; padding: 0 7px;">|</span>
고객센터 : 1588-3388
<span style="color: #515151; padding: 0 7px;">|</span>
사업자 등록번호 : ???-??-?????
</p>
</td>
</tr>
<!-- Copyright -->
<tr>
<td style="padding: 12px 0 0 121px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #8c959f; margin: 0;">
Copyright KWANGJU Bank. ALL Rights Reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- End Email Container -->
</td>
</tr>
</table>
<!-- End Email Wrapper -->
</body>
</html>
+235
View File
@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>이메일 인증 - 광주은행 API Portal</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style type="text/css">
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: 100% !important;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* Responsive styles */
@media screen and (max-width: 700px) {
.email-container {
width: 100% !important;
}
.content-section {
margin: 0 15px !important;
}
.content-inner {
padding: 30px 20px !important;
}
.header-section,
.footer-section {
padding: 20px 15px !important;
}
.verification-box {
width: 90% !important;
}
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #c3dfea;">
<!-- Email Wrapper -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #c3dfea;">
<tr>
<td align="center" style="padding: 0;">
<!-- Email Container -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="700" class="email-container" style="max-width: 700px; background-color: #c3dfea;">
<!-- Header Section -->
<tr>
<td class="header-section" style="padding: 14px 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="vertical-align: middle;">
<!-- Logo Image -->
<img src="email_logo_top.png" alt="광주은행" width="118" height="22" style="display: inline-block; vertical-align: middle;">
<span style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 16px; font-weight: 700; color: #212529; vertical-align: middle; padding-left: 8px;">API Portal</span>
</td>
<td style="vertical-align: middle; text-align: right;">
<!-- Lock Icon -->
<img src="email_icon_lock.png" alt="보안" width="74" height="60" style="display: inline-block; vertical-align: middle;">
</td>
</tr>
</table>
</td>
</tr>
<!-- Content Section -->
<tr>
<td style="padding: 0 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fdfdfd; border-top: 4px solid #0049b4;">
<tr>
<td class="content-inner" style="padding: 40px 30px;">
<!-- Title -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 40px;">
<h1 style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 28px; font-weight: 700; color: #2f3b51; margin: 0;">
[ 이메일 인증 ]
</h1>
</td>
</tr>
</table>
<!-- Greeting Message -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 30px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 20px; color: #000000; line-height: 36px; margin: 0; text-align: center;">
<span style="font-weight: 700;">{{userName}}님</span> 안녕하세요.<br>
광주은행 API Portal에 가입해 주셔서 감사합니다.<br>
아래 인증번호를 이메일 인증 페이지에 입력 해주세요.
</p>
</td>
</tr>
</table>
<!-- Verification Code Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" class="verification-box" style="width: 438px; max-width: 100%;">
<tr>
<td align="center" style="background-color: #ffffff; border: 6px solid #e5e7eb; border-radius: 20px; padding: 20px 30px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 24px; font-weight: 700; color: #0049b4; margin: 0; letter-spacing: 2px;">
{{verificationCode}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Verification Link -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 20px;">
<a href="{{verificationUrl}}" style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 18px; color: #212529; text-decoration: none; word-break: break-all;">
{{verificationUrl}}
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Section -->
<tr>
<td class="footer-section" style="padding: 25px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Footer Logo and Notice -->
<tr>
<td style="padding-bottom: 10px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="96" style="vertical-align: top; padding-right: 25px;">
<!-- Footer Logo -->
<img src="email_logo_bottom.png" alt="광주은행" width="96" height="18" style="display: block;">
</td>
<td style="vertical-align: top;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151; line-height: 20px; margin: 0;">
본 메일은 발신 전용으로 회신되지 않습니다.<br>
관련 문의사항은 고객센터 (1588-3388) 또는 홈페이지를 이용하시기 바랍니다.
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Links -->
<tr>
<td style="padding: 10px 0 0 121px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151;">
<a href="https://www.kjbank.com" style="color: #515151; text-decoration: none;">은행소개</a>
<span style="color: #515151; padding: 0 7px;">|</span>
<a href="https://www.kjbank.com/privacy" style="color: #515151; text-decoration: none;">개인정보처리방침</a>
<span style="color: #515151; padding: 0 7px;">|</span>
<a href="https://www.kjbank.com/faq" style="color: #515151; text-decoration: none;">자주 묻는 질문</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Info -->
<tr>
<td style="padding: 8px 0 0 121px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #515151; margin: 0;">
광주광역시 동구 제봉로225, 1층(대인동)
<span style="color: #515151; padding: 0 7px;">|</span>
고객센터 : 1588-3388
<span style="color: #515151; padding: 0 7px;">|</span>
사업자 등록번호 : ???-??-?????
</p>
</td>
</tr>
<!-- Copyright -->
<tr>
<td style="padding: 12px 0 0 121px;">
<p style="font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-size: 12px; color: #8c959f; margin: 0;">
Copyright KWANGJU Bank. ALL Rights Reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- End Email Container -->
</td>
</tr>
</table>
<!-- End Email Wrapper -->
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=gradle-8.7-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+113
View File
@@ -0,0 +1,113 @@
# 사업자등록번호 중복 체크 구현 작업 계획서
## 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
@@ -0,0 +1,165 @@
# 네비게이션 바 디자인 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
@@ -0,0 +1,105 @@
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
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,122 @@
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
@@ -0,0 +1,44 @@
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.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

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
+830 -2
View File
@@ -1,6 +1,834 @@
{
"name": "eactive-portal",
"name": "eapim-portal",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {}
"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==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"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"
}
},
"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==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"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==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.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==",
"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==",
"dev": true,
"license": "MIT"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/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,
"license": "MIT",
"optional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"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",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8.6"
},
"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==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.1.tgz",
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/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,
"license": "BSD-3-Clause",
"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"
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "eapim-portal",
"version": "1.0.0",
"description": "SASS build system for EAPIM Portal",
"scripts": {
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
"build": "npm run sass:build && npm run sass:build:minified",
"dev": "npm run sass:watch"
},
"devDependencies": {
"sass": "^1.69.5"
},
"author": "",
"license": "ISC",
"dependencies": {
"playwright": "^1.57.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
sass src/main/resources/static/sass/main.scss src/main/resources/static/css/main.css
+2 -2
View File
@@ -7,5 +7,5 @@ project (':elink-online-core-jpa').projectDir = new File(settingsDir, "../eapim-
project (':elink-portal-common').projectDir = new File(settingsDir, "../elink-portal-common")
include 'kjb-safedb'
project (':kjb-safedb').projectDir = new File(settingsDir, "../kjb-safedb")
//include 'kjb-safedb'
//project (':kjb-safedb').projectDir = new File(settingsDir, "../kjb-safedb")
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequestMapping("/agreements")
public class AgreementsController {
public static final String TERMS_AGREEMENTS = "fragment/kbank/terms_agreements";
public static final String TERMS_AGREEMENTS = "fragment/kjbank/terms_agreements";
private final AgreementsFacade agreementsFacade;
@@ -35,6 +35,12 @@ public class AgreementsDTO {
private LocalDateTime updatedDate;
/* 첨부파일 ID */
private String attachedFileId;
/* 첨부파일명 (확장자 포함) */
private String attachedFileName;
// 기본 생성자 추가
public AgreementsDTO() {
this.contents = ""; // null 방지를 위한 기본값 설정
@@ -7,30 +7,48 @@ import com.eactive.apim.portal.apps.agreements.dto.AgreementsDTO;
import com.eactive.apim.portal.apps.agreements.mapper.AgreementsMapper;
import com.eactive.apim.portal.apps.user.service.PortalUserPrivacyAgreementService;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.file.entity.FileDetail;
import com.eactive.apim.portal.file.service.FileService;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringEscapeUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
public class AgreementsFacadeImpl implements AgreementsFacade {
// 플레이스홀더 패턴: {{IMG:fileId:fileSn}} 또는 {{IMG:fileId:fileSn|속성}}
private static final Pattern PLACEHOLDER_PATTERN =
Pattern.compile("\\{\\{IMG:([a-f0-9\\-]+):(\\d+)(\\|[^}]+)?\\}\\}");
private final AgreementsService agreementsService;
private final AgreementsMapper agreementsMapper;
private final PortalUserPrivacyAgreementService userAgreementService;
private final FileService fileService;
@Value("${editor.image.base-url:/file/view}")
private String editorImageBaseUrl;
@Autowired
public AgreementsFacadeImpl(AgreementsService agreementsService,
AgreementsMapper agreementsMapper,
PortalUserPrivacyAgreementService userAgreementService) {
PortalUserPrivacyAgreementService userAgreementService,
FileService fileService) {
this.agreementsService = agreementsService;
this.agreementsMapper = agreementsMapper;
this.userAgreementService = userAgreementService;
this.fileService = fileService;
}
@Override
@@ -71,15 +89,79 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
}
}
// HTML 언이스케이프 처리
// HTML 언이스케이프 및 이미지 플레이스홀더 변환 처리
private AgreementsDTO convertToDTO(Agreements agreement) {
AgreementsDTO dto = agreementsMapper.map(agreement);
if (dto.getContents() != null) {
dto.setContents(StringEscapeUtils.unescapeHtml4(dto.getContents()));
String contents = StringEscapeUtils.unescapeHtml4(dto.getContents());
// 이미지 플레이스홀더를 HTML img 태그로 변환
contents = convertPlaceholdersToHtml(contents);
dto.setContents(contents);
}
// 첨부파일 정보 설정
if (StringUtils.isNotBlank(agreement.getAttachedFileId())) {
dto.setAttachedFileId(agreement.getAttachedFileId());
try {
FileDetail fileDetail = fileService.getFileDetailByIds(agreement.getAttachedFileId(), 1);
String fileName = fileDetail.getOriginalFileName();
if (StringUtils.isNotBlank(fileDetail.getFileExtension())) {
fileName += "." + fileDetail.getFileExtension();
}
dto.setAttachedFileName(fileName);
} catch (Exception e) {
log.warn("첨부파일 정보 조회 실패: fileId={}", agreement.getAttachedFileId(), e);
}
}
return dto;
}
/**
* 플레이스홀더를 실제 이미지 태그로 변환
* {{IMG:fileId:fileSn}} 또는 {{IMG:fileId:fileSn|width=300|height=200|style=...}}
* → <img src="/file/view?fileId={fileId}&fileSn={fileSn}" width="300" height="200" style="..." />
*/
private String convertPlaceholdersToHtml(String contents) {
if (contents == null) {
return null;
}
Matcher matcher = PLACEHOLDER_PATTERN.matcher(contents);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String fileId = matcher.group(1);
String fileSn = matcher.group(2);
String attrs = matcher.group(3);
StringBuilder img = new StringBuilder();
img.append("<img src=\"").append(editorImageBaseUrl)
.append("?fileId=").append(fileId)
.append("&fileSn=").append(fileSn).append("\"");
// 속성 파싱 및 추가
if (attrs != null && !attrs.isEmpty()) {
String[] parts = attrs.substring(1).split("\\|"); // 앞의 | 제거 후 분리
for (String part : parts) {
String[] kv = part.split("=", 2);
if (kv.length == 2) {
img.append(" ").append(kv[0]).append("=\"").append(kv[1]).append("\"");
}
}
}
// 기본 스타일 (style 속성이 없으면)
if (attrs == null || !attrs.contains("style=")) {
img.append(" style=\"max-width:100%;height:auto;\"");
}
img.append(" />");
matcher.appendReplacement(sb, Matcher.quoteReplacement(img.toString()));
}
matcher.appendTail(sb);
return sb.toString();
}
@Override
public AgreementsDTO getAgreement(String agreementsTypeCode) {
LocalDateTime date = LocalDateTime.now();
@@ -142,15 +224,7 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
return Collections.singletonList(createDefaultAgreement(String.valueOf(agreementTypeCode)));
}
return agreementsList.stream()
.map(agreement -> {
AgreementsDTO dto = agreementsMapper.map(agreement);
// HTML 언이스케이프 처리
if (dto.getContents() != null) {
dto.setContents(StringEscapeUtils.unescapeHtml4(dto.getContents()));
}
return dto;
})
.map(this::convertToDTO)
.collect(Collectors.toList());
}
@@ -4,18 +4,11 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceApiDTO;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.GetMapping;
@@ -28,11 +21,8 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
public class ApiAjaxController {
private final static String STG_SERVER = "STG";
private final ApiService apiService;
private final ApiServiceService apiServiceService;
private final AppServiceFacade appServiceFacade;
public Map<String, ApiServiceDTO> getMainIconsFromServiceDtos(List<ApiServiceDTO> apiServices) {
Map<String, ApiServiceDTO> mainIconsMap = new HashMap<>();
@@ -54,53 +44,15 @@ public class ApiAjaxController {
Map<String, ApiServiceDTO> mainIconsMap = getMainIconsFromServiceDtos(apiServices);
List<ApiSpecInfoDto> apiSpecInfoDtos = apiService.findApis();
for (ApiSpecInfoDto spec : apiSpecInfoDtos) {
ApiServiceDTO serviceDTO = mainIconsMap.get(spec.getApiId());
if (serviceDTO != null) {
String mainIcon = serviceDTO.getMainIcon();
String service = serviceDTO.getGroupName();
spec.setMainIcon(mainIcon);
spec.setService(service);
}
}
return apiSpecInfoDtos;
}
@GetMapping("/for_request_prod")
@Secured("ROLE_API_KEY_REQUEST")
public List<ApiSpecInfoDto> apiRequestProd(@ModelAttribute ApiGroupSearch search) {
List<ApiServiceDTO> apiServices = apiServiceService.searchApiGroups(search);
Map<String, ApiServiceDTO> mainIconsMap = getMainIconsFromServiceDtos(apiServices);
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
List<ClientDTO> apikeys = appServiceFacade.getApiKeyList(user.getPortalOrg(), STG_SERVER);
List<ApiServiceApiDTO> apis = apikeys.stream()
.flatMap(client -> client.getApiList().stream()) // flatten all apiLists into a single stream of ApiServiceApiDTO
.distinct() // remove duplicates
.collect(Collectors.toList()); // collect to List
List<ApiSpecInfoDto> apiSpecInfoDtos = new ArrayList<>();
apis.forEach(api -> {
ApiSpecInfoDto specInfoDto = new ApiSpecInfoDto();
specInfoDto.setApiId(api.getApiId());
specInfoDto.setApiName(api.getApiDesc());
apiSpecInfoDtos.add(specInfoDto);
});
for (ApiSpecInfoDto spec : apiSpecInfoDtos) {
ApiServiceDTO serviceDTO = mainIconsMap.get(spec.getApiId());
if (serviceDTO != null) {
String mainIcon = serviceDTO.getMainIcon();
String service = serviceDTO.getGroupName();
spec.setMainIcon(mainIcon);
spec.setService(service);
}
}
return apiSpecInfoDtos;
// Filter APIs that have matching service information
return apiSpecInfoDtos.stream()
.filter(spec -> mainIconsMap.containsKey(spec.getApiId()))
.peek(spec -> {
ApiServiceDTO serviceDTO = mainIconsMap.get(spec.getApiId());
spec.setMainIcon(serviceDTO.getMainIcon());
spec.setService(serviceDTO.getGroupName());
})
.collect(java.util.stream.Collectors.toList());
}
}
@@ -1,17 +1,17 @@
package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSearchData;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import java.util.*;
import com.eactive.apim.portal.common.exception.NotFoundException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -81,7 +81,7 @@ public class ApiTesterFilter implements Filter {
String scope = params.getOrDefault("scope", "default");
String token = "{\n" +
" \"access_token\": \"kbank_gw_sample_token\",\n" +
" \"access_token\": \"kjbank_gw_sample_token\",\n" +
" \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" +
@@ -94,7 +94,7 @@ public class ApiTesterFilter implements Filter {
} else {
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
if (apiSpecInfoDto.getResponseType().equals("sample")) {
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
} else {
@@ -1,112 +0,0 @@
package com.eactive.apim.portal.apps.app.controller;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apps.apis.dto.ApiSearchData;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.dto.ClientSimpleDTO;
import com.eactive.apim.portal.apps.app.mapper.CredentialMapper;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.common.dto.ResponseDTO;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.stream.Collectors;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.access.annotation.Secured;
import org.springframework.ui.Model;
import org.springframework.validation.ValidationUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/myapikey")
@RequiredArgsConstructor
@Secured("ROLE_API_KEY_REQUEST")
public class ApiKeyRestController {
private final static String PROD_SERVER = "PROD";
private final static String STG_SERVER = "STG";
private final AppServiceFacade appServiceFacade;
private final CredentialMapper credentialMapper;
private final EncryptionUtil encryptionUtil;
private final ApiSearchFacade apiSearchFacade;
@PostMapping("/api_key_request")
public ResponseDTO apiRequest(@RequestBody AppRequestDTO appRequest) {
appRequest.setOrg(SecurityUtil.getUserOrg());
if (appRequest.getType() == null) {
appRequest.setType(AppRequestType.NEW);
}
AppRequestDTO appRequestDTO = appServiceFacade.createAppRequest(appRequest);
appServiceFacade.beginApproval(appRequestDTO.getApproval().getId());
return new ResponseDTO(200, "SUCCESS", appRequestDTO.getType().getDescription() + " 신청이 완료되었습니다.");
}
@PostMapping("/api_key_request_prod")
public ResponseDTO requestProdApiKey(@RequestBody AppRequestDTO appRequest) {
appRequest.setOrg(SecurityUtil.getUserOrg());
if (appRequest.getClientId() != null && !appRequest.getClientId().isEmpty()) {
try {
appRequest.setClientId(encryptionUtil.decrypt(appRequest.getClientId()));
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
return new ResponseDTO(400, "FAILURE", "API 키 정보가 올바르지 않습니다.");
}
appRequest.setType(AppRequestType.PROD_MODIFY);
} else {
appRequest.setType(AppRequestType.PROD_NEW);
}
AppRequestDTO appRequestDTO = appServiceFacade.createAppRequest(appRequest);
appServiceFacade.beginApproval(appRequestDTO.getApproval().getId());
return new ResponseDTO(200, "SUCCESS", appRequestDTO.getType().getDescription() + " 신청이 완료되었습니다.");
}
@GetMapping("/dev")
public List<ClientSimpleDTO> apiKeyList() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
List<ClientDTO> apikeys = appServiceFacade.getApiKeyList(user.getPortalOrg(), STG_SERVER);
ApiSearchData apiSearchData = apiSearchFacade.loadAllApiData();
for (ClientDTO apikey : apikeys) {
apikey.getApiList().forEach(api -> {
api.setService(apiSearchData.getServicesByApiId().get(api.getApiId()).getGroupName());
});
}
return apikeys.stream().map(credentialMapper::mapToClientSimpleDTO).collect(Collectors.toList());
}
@PostMapping("/prod")
public ClientSimpleDTO apiKeyProdList(@RequestBody AppRequestDTO appRequest) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
try {
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), encryptionUtil.decrypt(appRequest.getClientId()), PROD_SERVER);
ApiSearchData apiSearchData = apiSearchFacade.loadAllApiData();
apiKey.getApiList().forEach(api -> {
api.setService(apiSearchData.getServicesByApiId().get(api.getApiId()).getGroupName());
});
apiKey.setClientid(encryptionUtil.encrypt(apiKey.getClientid()));
return credentialMapper.mapToClientSimpleDTO(apiKey);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
return new ClientSimpleDTO();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
package com.eactive.apim.portal.apps.app.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
import org.springframework.web.multipart.MultipartFile;
/**
* API Key Registration Session Data
* Holds data across multiple registration steps
*/
@Data
public class ApiKeyRegistrationDTO implements Serializable {
private static final long serialVersionUID = 1L;
// Client ID for modification operations
private String clientId;
// Request type for tracking operation type
private String requestType; // "NEW" or "MODIFY"
// Existing app icon file ID (for modifications)
private String appIconFileId;
// Step 1: Basic Information
// Transient field for file upload (not serialized in session)
private transient MultipartFile appIcon;
// Persisted file data
private String appIconFileName;
private byte[] appIconData;
private String appIconContentType;
@NotBlank(message = "앱 이름을 입력해주세요.")
@Length(max = 100, message = "앱 이름은 100자를 초과할 수 없습니다.")
private String appName;
@NotBlank(message = "앱 설명을 입력해주세요.")
@Length(max = 500, message = "앱 설명은 500자를 초과할 수 없습니다.")
private String appDescription;
private String callbackUrl;
private List<String> ipWhitelist = new ArrayList<>();
// Step 2: API Selection
private List<String> selectedApis = new ArrayList<>();
// Helper method to add IP to whitelist
public void addIpAddress(String ip) {
if (ip != null && !ip.trim().isEmpty() && !ipWhitelist.contains(ip)) {
ipWhitelist.add(ip);
}
}
// Helper method to set IP whitelist from comma-separated string
public void setIpWhitelistFromString(String ipString) {
ipWhitelist.clear();
if (ipString != null && !ipString.trim().isEmpty()) {
String[] ips = ipString.split(",");
for (String ip : ips) {
String trimmedIp = ip.trim();
if (!trimmedIp.isEmpty()) {
ipWhitelist.add(trimmedIp);
}
}
}
}
// Helper method to get IP whitelist as comma-separated string
public String getIpWhitelistAsString() {
return String.join(",", ipWhitelist);
}
// Helper method to get app icon as base64 data URI for image display
public String getAppIconBase64() {
if (appIconData == null || appIconData.length == 0) {
return null;
}
String base64Data = Base64.getEncoder().encodeToString(appIconData);
String contentType = appIconContentType != null ? appIconContentType : "image/png";
return "data:" + contentType + ";base64," + base64Data;
}
// Validation helpers
public boolean isStep1Complete() {
// callbackUrl and appIcon are optional, so we only check required fields
return appName != null && !appName.trim().isEmpty() &&
appDescription != null && !appDescription.trim().isEmpty();
}
public boolean isStep2Complete() {
return selectedApis != null && !selectedApis.isEmpty();
}
public boolean isComplete() {
return isStep1Complete() && isStep2Complete();
}
}
@@ -44,6 +44,14 @@ public class AppRequestDTO {
private String reason;
private String appDescription;
private String callbackUrl;
private String ipWhitelist;
private String appIconFileId;
private LocalDateTime createdDate;
private String prevApiList;
@@ -45,6 +45,10 @@ public class ClientDTO {
private String appstatus;
private String appDescription;
private String appIconFileId;
private List<ApiServiceApiDTO> apiList = new ArrayList<>();
}
@@ -4,6 +4,7 @@ package com.eactive.apim.portal.apps.app.mapper;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apps.apiservice.mapper.ApiServiceApiMapper;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.approval.mapper.ApprovalMapper;
import com.eactive.apim.portal.apps.user.mapper.PortalOrgMapper;
import com.eactive.eai.data.mapper.BaseMapperConfig;
@@ -13,4 +14,53 @@ import org.mapstruct.Mapper;
@Mapper(config = BaseMapperConfig.class, uses = {PortalOrgMapper.class, ApiServiceApiMapper.class, ApprovalMapper.class})
public interface AppRequestMapper extends GenericMapper<AppRequestDTO, AppRequest> {
/**
* AppRequest를 ClientDTO로 간단하게 매핑합니다.
* 뷰에서 AppRequest를 ClientDTO와 동일한 형식으로 표시하기 위해 사용됩니다.
*
* @param appRequest 앱 요청 엔티티
* @return ClientDTO 표시용 DTO
*/
default ClientDTO mapToClientDTO(AppRequest appRequest) {
if (appRequest == null) {
return null;
}
ClientDTO dto = new ClientDTO();
// 기본 식별 정보
dto.setClientid(appRequest.getId()); // AppRequest ID를 임시 client ID로 사용
dto.setClientname(appRequest.getClientName());
// 앱 상세 정보
dto.setAppDescription(appRequest.getAppDescription());
dto.setAppIconFileId(appRequest.getAppIconFileId());
// 네트워크 설정
dto.setRedirecturi(appRequest.getCallbackUrl());
dto.setAllowedips(appRequest.getIpWhitelist());
// 상태 정보 (ApprovalStatus → appstatus 매핑)
if (appRequest.getStatus() != null) {
switch (appRequest.getStatus()) {
case APPROVED:
dto.setAppstatus("1"); // 승인됨 = 활성화
break;
case DENIED:
dto.setAppstatus("0"); // 거부됨 = 비활성화
break;
default:
dto.setAppstatus("2"); // 대기중/진행중 = 보류
break;
}
}
// 수정 날짜 (createdDate를 modifiedon으로 사용)
dto.setModifiedon(appRequest.getCreatedDate());
// API 목록은 별도 처리 필요 (콤마로 구분된 문자열 → List<ApiServiceApiDTO>)
// 필요시 컨트롤러에서 추가 처리
return dto;
}
}
@@ -7,12 +7,14 @@ import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceApiDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.mapper.AppRequestMapper;
@@ -21,16 +23,24 @@ import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
import com.eactive.apim.portal.apps.user.mapper.PortalOrgMapper;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.*;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@@ -47,53 +57,64 @@ public class AppServiceFacade {
private final AppRequestMapper appRequestMapper;
private final PortalOrgMapper portalOrgMapper;
private final ApiServiceHelper apiServiceHelper;
private final FileService fileService;
private final PasswordEncoder passwordEncoder;
public Page<ClientDTO> getApiKeyPageForServer(Pageable pageable, PortalOrg portalOrg, String server) {
Page<Credential> clients = credentialRepository.findAllByOrgidAndServer(pageable, portalOrg.getId(), server);
return clients.map(credentialMapper::toVo);
}
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
public List<ClientDTO> getApiKeyList(PortalOrg portalOrg, String server) {
List<Credential> clients = credentialRepository.findAllByOrgidAndServer(portalOrg.getId(), server);
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId());
return clients.stream().map(credentialMapper::toVo).collect(Collectors.toList());
}
public ClientDTO getApiKey(String orgid, String clientId, String server) {
return credentialRepository.findByClientidAndOrgidAndServer(clientId, orgid, server).map(credentialMapper::toVo).orElse(null);
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE),
Arrays.asList(new ProcessingState(), new RequestedState()));
return appRequests;
}
public ClientDTO getApiKey(String orgid, String clientId) {
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
}
/**
* AppRequest ID와 조직 정보로 AppRequest를 조회합니다.
* API 그룹 및 API 목록 정보를 함께 처리하여 반환합니다.
*
* @param id AppRequest ID
* @param portalOrg 포털 조직
* @return AppRequest DTO (없으면 null)
*/
public AppRequestDTO getAppRequestById(String id, PortalOrg portalOrg) {
return getApiRequestByIdAndPortalOrg(id, portalOrg);
}
public Page<AppRequestDTO> getApiRequestHistory(Pageable pageable, PortalOrgDTO portalOrg) {
return appRequestRepository.findAllByOrgAndTypeIsIn(pageable, portalOrgMapper.toEntity(portalOrg), Arrays.asList(AppRequestType.NEW,AppRequestType.MODIFY, AppRequestType.DELETE)).map(appRequestMapper::toVo);
}
public Page<AppRequestDTO> getApiRequestProdHistory(Pageable pageable, PortalOrgDTO portalOrg) {
return appRequestRepository.findAllByOrgAndTypeIsIn(pageable, portalOrgMapper.toEntity(portalOrg), Arrays.asList(AppRequestType.PROD_NEW,AppRequestType.PROD_MODIFY, AppRequestType.PROD_DELETE)).map(appRequestMapper::toVo);
return appRequestRepository.findAllByOrgAndTypeIsIn(pageable, portalOrgMapper.toEntity(portalOrg), Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE)).map(appRequestMapper::toVo);
}
public AppRequestDTO createAppRequest(AppRequestDTO appRequest) {
setApiGroupsFromApiList(appRequest);
AppRequest request = appRequestRepository.save(appRequestMapper.toEntity(appRequest));
if (request.getType() != null && (request.getType().equals(AppRequestType.MODIFY) || request.getType().equals(AppRequestType.DELETE) ||
request.getType().equals(AppRequestType.PROD_MODIFY) || request.getType().equals(AppRequestType.PROD_DELETE))) {
if (request.getType() != null && (request.getType().equals(AppRequestType.MODIFY) || request.getType().equals(AppRequestType.DELETE))) {
Optional<Credential> client = credentialRepository.findById(request.getClientId());
if (client.isPresent()) {
request.setClientName(client.get().getClientname());
if(client.get().getApiList() != null) {
if (client.get().getApiList() != null) {
// 기존 API 목록을 저장
String existingApiList = client.get().getApiList().stream()
.map(ApiSpecInfo::getApiId)
.collect(Collectors.joining(","));
.map(ApiSpecInfo::getApiId)
.collect(Collectors.joining(","));
request.setPrevApiList(existingApiList);
}
if(AppRequestType.DELETE.equals(request.getType()) || AppRequestType.PROD_DELETE.equals(request.getType())) {
if (AppRequestType.DELETE.equals(request.getType())) {
request.setApiList(request.getPrevApiList());
}
} else {
@@ -108,22 +129,6 @@ public class AppServiceFacade {
return appRequestMapper.toVo(request);
}
private void setApiGroupsFromApiList(AppRequestDTO appRequest) {
if (StringUtils.isEmpty(appRequest.getApiList())) {
return;
}
Set<String> uniqueGroupIds = Arrays.stream(appRequest.getApiList().split(","))
.map(apiId -> apiServiceService.findApiServiceByApiId(apiId))
.filter(Objects::nonNull)
.flatMap(apiService -> apiService.getApiGroupApiList().stream())
.map(ApiServiceApiDTO::getApiGroupId)
.collect(Collectors.toSet());
appRequest.setApiGroupList(String.join(",", uniqueGroupIds));
}
public void beginApproval(String approvalId) {
approvalService.beginApproval(approvalId);
}
@@ -132,27 +137,11 @@ public class AppServiceFacade {
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval);
}
public List<AppRequestDTO> getProdApiRequestHistory(String clientid) {
return appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(clientid,Arrays.asList(AppRequestType.PROD_NEW,AppRequestType.PROD_MODIFY, AppRequestType.PROD_DELETE)).stream().map(appRequestMapper::toVo).collect(Collectors.toList());
}
public AppRequestDTO getApiRequestProdByIdAndPortalOrg(String id, PortalOrg portalOrg) {
AppRequestDTO appRequest = appRequestRepository.findByIdAndOrgAndTypeIsIn(id, portalOrg,
Arrays.asList(AppRequestType.PROD_NEW,AppRequestType.PROD_MODIFY, AppRequestType.PROD_DELETE)).map(this::convertToAppRequestDTO).orElse(null);
if(appRequest != null) {
processApiGroups(appRequest);
processApiList(appRequest);
}
return appRequest;
}
public AppRequestDTO getApiRequestByIdAndPortalOrg(String id, PortalOrg org) {
AppRequestDTO appRequest = appRequestRepository.findByIdAndOrg(id, org).map(this::convertToAppRequestDTO).orElse(null);
if(appRequest != null) {
processApiGroups(appRequest);
if (appRequest != null) {
processApiList(appRequest);
}
return appRequest;
@@ -167,16 +156,6 @@ public class AppServiceFacade {
return dto;
}
private void processApiGroups(AppRequestDTO appRequest) {
if (!StringUtils.isEmpty(appRequest.getApiGroupList())) {
String[] apiGroupList = appRequest.getApiGroupList().split(",");
for (String apiGroupId : apiGroupList) {
appRequest.getApiServiceDTOList().add(apiServiceService.getApiGroupById(apiGroupId));
}
}
}
private void processApiList(AppRequestDTO appRequest) {
if (!StringUtils.isEmpty(appRequest.getApiList())) {
@@ -192,4 +171,140 @@ public class AppServiceFacade {
}
}
}
/**
* ApiKeyRegistrationDTO를 기반으로 AppRequest를 생성합니다.
*
* @param registration API Key 등록 정보
* @param portalOrg 포털 조직
* @return 생성된 AppRequest DTO
* @throws IOException 파일 처리 중 오류 발생 시
*/
public AppRequestDTO createAppRequestFromRegistration(ApiKeyRegistrationDTO registration, PortalOrg portalOrg) throws IOException {
// 1. 앱 아이콘 파일 저장
String appIconFileId = null;
if (registration.getAppIconData() != null && registration.getAppIconData().length > 0) {
FileInfo fileInfo = fileService.createSingleFileFromBytes(registration.getAppIconFileName(), registration.getAppIconContentType(), registration.getAppIconData());
appIconFileId = fileInfo.getFileId();
}
// 2. AppRequestDTO 생성 및 필드 매핑
AppRequestDTO appRequestDTO = new AppRequestDTO();
appRequestDTO.setOrg(portalOrgMapper.toVo(portalOrg));
appRequestDTO.setType(AppRequestType.NEW);
// 기본 정보 매핑
appRequestDTO.setClientName(registration.getAppName());
appRequestDTO.setAppDescription(registration.getAppDescription());
appRequestDTO.setCallbackUrl(registration.getCallbackUrl());
appRequestDTO.setAppIconFileId(appIconFileId);
// IP 화이트리스트 매핑 (List<String> → comma-separated String)
if (registration.getIpWhitelist() != null && !registration.getIpWhitelist().isEmpty()) {
appRequestDTO.setIpWhitelist(String.join(",", registration.getIpWhitelist()));
}
// 선택된 API 목록 매핑 (List<String> → comma-separated String)
if (registration.getSelectedApis() != null && !registration.getSelectedApis().isEmpty()) {
appRequestDTO.setApiList(String.join(",", registration.getSelectedApis()));
}
// 3. 기존 createAppRequest 메소드 호출하여 승인 프로세스 시작
return createAppRequest(appRequestDTO);
}
/**
* ApiKeyRegistrationDTO를 기반으로 수정 AppRequest를 생성합니다.
*
* @param registration API Key 수정 정보
* @param portalOrg 포털 조직
* @return 생성된 AppRequest DTO
* @throws IOException 파일 처리 중 오류 발생 시
*/
public AppRequestDTO createModifyRequestFromRegistration(ApiKeyRegistrationDTO registration, PortalOrg portalOrg) throws IOException {
// 1. 기존 Credential 정보 조회
ClientDTO existingClient = getApiKey(portalOrg.getId(), registration.getClientId());
if (existingClient == null) {
throw new NotFoundException("Client not found: " + registration.getClientId());
}
// 2. 앱 아이콘 파일 저장 (변경된 경우)
String appIconFileId = null;
// 새로운 아이콘 데이터가 있으면 저장
if (registration.getAppIconData() != null && registration.getAppIconData().length > 0) {
FileInfo fileInfo = fileService.createSingleFileFromBytes(registration.getAppIconFileName(), registration.getAppIconContentType(), registration.getAppIconData());
appIconFileId = fileInfo.getFileId();
}
// 새로운 아이콘이 없으면 기존 아이콘 ID 사용 (registration에서 가져오거나 기존 클라이언트에서 가져옴)
else if (registration.getAppIconFileId() != null) {
appIconFileId = registration.getAppIconFileId();
} else {
appIconFileId = existingClient.getAppIconFileId();
}
// 3. AppRequestDTO 생성 및 필드 매핑
AppRequestDTO appRequestDTO = new AppRequestDTO();
appRequestDTO.setOrg(portalOrgMapper.toVo(portalOrg));
appRequestDTO.setType(AppRequestType.MODIFY);
appRequestDTO.setClientId(registration.getClientId()); // 수정할 클라이언트 ID 설정
// 기본 정보 매핑
appRequestDTO.setClientName(registration.getAppName());
appRequestDTO.setAppDescription(registration.getAppDescription());
appRequestDTO.setCallbackUrl(registration.getCallbackUrl());
appRequestDTO.setAppIconFileId(appIconFileId);
// IP 화이트리스트 매핑 (List<String> → comma-separated String)
if (registration.getIpWhitelist() != null && !registration.getIpWhitelist().isEmpty()) {
appRequestDTO.setIpWhitelist(String.join(",", registration.getIpWhitelist()));
}
// 선택된 API 목록 매핑 (List<String> → comma-separated String)
if (registration.getSelectedApis() != null && !registration.getSelectedApis().isEmpty()) {
appRequestDTO.setApiList(String.join(",", registration.getSelectedApis()));
}
// 4. 기존 createAppRequest 메소드 호출하여 승인 프로세스 시작
// createAppRequest 내부에서 MODIFY 타입인 경우 기존 API 목록을 prevApiList에 저장함
return createAppRequest(appRequestDTO);
}
/**
* 사용자의 비밀번호를 검증합니다.
* Client Secret 조회 등 민감한 작업 수행 시 본인 확인을 위해 사용됩니다.
*
* @param user 인증된 사용자 정보
* @param password 검증할 비밀번호 (평문)
* @return 비밀번호 일치 여부
*/
public boolean verifyUserPassword(PortalAuthenticatedUser user, String password) {
if (user == null || password == null) {
return false;
}
try {
// PasswordEncoder를 사용하여 평문 비밀번호와 저장된 해시를 비교
return passwordEncoder.matches(password, user.getPassword());
} catch (Exception e) {
// 비밀번호 검증 중 오류 발생 시 false 반환 (보안상 구체적인 오류 노출 방지)
return false;
}
}
/**
* API Key(Credential)를 즉시 삭제합니다.
* 승인 프로세스 없이 바로 삭제 처리됩니다.
*
* @param orgId 조직 ID
* @param clientId 삭제할 클라이언트 ID
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
*/
public void deleteApp(String orgId, String clientId) {
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
credentialRepository.delete(credential);
}
}
@@ -2,7 +2,7 @@ package com.eactive.apim.portal.apps.auth.service;
public interface AuthNumberService {
void sendRequestAuthNumber(String recipientKey, String msgType);
String sendRequestAuthNumber(String recipientKey, String msgType);
boolean verifyAuthNumber(String recipientKey, String authNumber);
}
@@ -40,10 +40,11 @@ public class AuthNumberServiceImpl implements AuthNumberService {
/**
* @param recipientKey
* @param msgType
* @return 생성된 인증번호
*/
@Override
@Transactional(noRollbackFor = AuthNumberException.class)
public void sendRequestAuthNumber(String recipientKey, String msgType) {
public String sendRequestAuthNumber(String recipientKey, String msgType) {
logger.info("Sending auth number to: {} via {}", recipientKey, msgType);
validateResendTime(recipientKey);
@@ -55,6 +56,8 @@ public class AuthNumberServiceImpl implements AuthNumberService {
storage.saveAuthNumber(recipientKey, authNumber,
LocalDateTime.now().plusSeconds(authNumberExpirationTime));
return authNumber;
}
@Override
@@ -0,0 +1,26 @@
package com.eactive.apim.portal.apps.commission.constant;
/**
* Application constants.
*/
public final class Constants {
public static final String SYSTEM_ACCOUNT = "system";
public static final String APIM_FOR_OBP_KEY = "x-obp-trust-system";
public static final String APIM_CODE_FOR_OBP_KEY = "x-obp-partnercode";
public static final String SMS_URL = "/api/messaging/sms";
public static final String COMMISSION_URL = "/api/billing/billing/findBillingForCondition";
public static final String COMMISSION_PRINT_URL = "/api/billing/billing/findBillingForCondition/print";
public static final String OBP_ORGANIZATION_URL = "/api/customer/findCustomerByBusinessManRegistrationNo/";
public static String LANGUAGE = "ko";
private Constants() {
}
}
@@ -0,0 +1,320 @@
package com.eactive.apim.portal.apps.commission.controller;
import com.eactive.apim.portal.apps.commission.constant.Constants;
import com.eactive.apim.portal.apps.commission.dto.CommissionDTO;
import com.eactive.apim.portal.apps.commission.dto.CommissionSearch;
import com.eactive.apim.portal.apps.commission.exception.ErrorUtil;
import com.eactive.apim.portal.apps.commission.service.ExternalService;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections.MapUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping(value = "/commission")
@Secured("ROLE_APP")
@RequiredArgsConstructor
public class CommissionController {
private final Logger log = LoggerFactory.getLogger(CommissionController.class);
private final ExternalService externalService;
private final PortalPropertyService portalPropertyService;
private final ObjectMapper objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
@GetMapping("/manage")
public String commissionManagePage(Model model) {
// 기본 정보 설정
model.addAttribute("organization", SecurityUtil.getUserOrg());
model.addAttribute("noOrgCode", StringUtils.isEmpty(SecurityUtil.getUserOrg().getOrgCode()));
return "apps/commission/commissionManage";
}
@GetMapping("/print/{year}/{month}")
public String commissionPrintPage(
@PathVariable("year") String year,
@PathVariable("month") String month,
Model model) {
CommissionSearch search = new CommissionSearch();
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
search.setYear(year);
search.setMonth(month);
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
if (result.hasError()) {
model.addAttribute("error", result.getError());
return "apps/commission/commissionPrint";
}
// Model에 데이터 바인딩
model.addAttribute("commission", result.getData());
model.addAttribute("organization", SecurityUtil.getUserOrg().getOrgName());
model.addAttribute("year", year);
model.addAttribute("month", month);
model.addAttribute("toDay", java.time.LocalDate.now().toString());
return "apps/commission/commissionPrint";
}
// ==================== API Endpoints ====================
@PostMapping("/print/check")
public ResponseEntity<?> checkPrintData(@RequestBody CommissionSearch search) {
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
if (StringUtils.isEmpty(search.getPartnerCode())) {
return ErrorUtil.create("fail.commission.needPartnerCode");
}
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
if (result.hasError()) {
return ErrorUtil.create("fail.commission.print", result.getError());
}
return ResponseEntity.ok().body(result.getData());
}
@PostMapping()
public ResponseEntity<?> list(@RequestBody CommissionSearch search) {
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
if (StringUtils.isEmpty(search.getPartnerCode())) {
return ErrorUtil.create("fail.commission.needPartnerCode");
}
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_URL);
if (result.hasError()) {
return ErrorUtil.create("fail.commission.list", result.getError());
}
return ResponseEntity.ok().body(result.getData());
}
// ==================== Private Methods ====================
/**
* 외부 API를 호출하여 수수료 데이터를 조회합니다.
*
* @param search 검색 조건
* @param apiPath API 경로 (Constants.COMMISSION_URL 또는 Constants.COMMISSION_PRINT_URL)
* @return 조회 결과
*/
private CommissionResult fetchCommissionData(CommissionSearch search, String apiPath) {
if (StringUtils.isEmpty(search.getPartnerCode())) {
return CommissionResult.error("파트너 코드가 없습니다.");
}
try {
String obmUrl = portalPropertyService.getPortalPropertiesAsMap("Portal").getOrDefault("obp.obm.url", "");
String url = obmUrl + apiPath;
// 첫 번째 API 호출
String result = externalService.getResponseHttpPost(url, toJson(search));
if (StringUtils.isEmpty(result)) {
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
}
HashMap<String, Object> returnObj = objectMapper.readValue(result, HashMap.class);
// 에러 응답 체크
String errorMessage = extractErrorMessage(returnObj);
if (errorMessage != null) {
return CommissionResult.error(errorMessage);
}
// Together 관련 추가 처리 (000002-01)
if (StringUtils.pathEquals(search.getPartnerCode(), "000002-01")) {
log.debug("Found Together : " + search.getPartnerCode());
CommissionSearch togetherSearch = new CommissionSearch();
togetherSearch.setPartnerCode("000002-02");
togetherSearch.setYear(search.getYear());
togetherSearch.setMonth(search.getMonth());
String togetherResult = externalService.getResponseHttpPost(url, toJson(togetherSearch));
if (StringUtils.isEmpty(togetherResult)) {
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
}
HashMap<String, Object> togetherReturnObj = objectMapper.readValue(togetherResult, HashMap.class);
log.debug("togetherLendingReturnObj : " + togetherReturnObj.toString());
String togetherErrorMessage = extractErrorMessage(togetherReturnObj);
if (togetherErrorMessage != null) {
return CommissionResult.error(togetherErrorMessage);
}
HashMap<String, Object> mergeMap = mergeCommissionData(returnObj, togetherReturnObj);
result = objectMapper.writeValueAsString(mergeMap);
}
CommissionDTO commissionDTO = objectMapper.readValue(result, CommissionDTO.class);
return CommissionResult.success(commissionDTO);
} catch (Exception e) {
log.warn(e.getMessage(), e);
return CommissionResult.error("수수료 조회에 실패하였습니다.");
}
}
/**
* 응답에서 에러 메시지를 추출합니다.
*/
private String extractErrorMessage(HashMap<String, Object> response) {
if (!StringUtils.isEmpty(response.get("error"))) {
HashMap<String, Object> error = (HashMap) response.get("error");
return error.get("message").toString();
}
return null;
}
private String toJson(CommissionSearch search) {
try {
Map<String, String> jsonMap = new HashMap<>();
jsonMap.put("partnerCode", search.getPartnerCode());
// 월을 두 자리로 패딩 (예: 1 -> 01, 12 -> 12)
String paddedMonth = String.format("%02d", Integer.parseInt(search.getMonth()));
jsonMap.put("targetOnMonth", search.getYear() + paddedMonth);
return objectMapper.writeValueAsString(jsonMap);
} catch (Exception e) {
log.warn("Failed to serialize CommissionSearch to JSON", e);
return "{}";
}
}
/**
* Safely parse double value from HashMap
* Handles both numeric types and string representations
*/
private double safeGetDouble(HashMap<String, Object> map, String key) {
Object value = map.get(key);
if (value == null) {
return 0.0;
}
try {
if (value instanceof Number) {
return ((Number) value).doubleValue();
} else if (value instanceof String) {
return Double.parseDouble((String) value);
}
return 0.0;
} catch (NumberFormatException e) {
log.warn("Failed to parse double value for key: {} with value: {}", key, value);
return 0.0;
}
}
public HashMap<String, Object> mergeCommissionData(HashMap<String, Object> o1, HashMap<String, Object> o2) {
HashMap<String, Object> mergeMap = new HashMap<String, Object>();
String o1Status = MapUtils.getString(o1, "status");
String o2Status = MapUtils.getString(o2, "status");
String resultStatus = null;
if (Integer.parseInt(o1Status) <= Integer.parseInt(o2Status)) {
resultStatus = o1Status;
} else {
resultStatus = o2Status;
}
double apiTotalValueSum = safeGetDouble(o1, "apiTotalValueSum") + safeGetDouble(o2, "apiTotalValueSum");
double apiSum = safeGetDouble(o1, "apiSum") + safeGetDouble(o2, "apiSum");
double apiTotalValueSum2 = safeGetDouble(o1, "apiTotalValueSum2") + safeGetDouble(o2, "apiTotalValueSum2");
double apiSum2 = safeGetDouble(o1, "apiSum2") + safeGetDouble(o2, "apiSum2");
double productTotalValueCountSum = safeGetDouble(o1, "productTotalValueCountSum") + safeGetDouble(o2, "productTotalValueCountSum");
double productTotalValueAmountSum = safeGetDouble(o1, "productTotalValueAmountSum") + safeGetDouble(o2, "productTotalValueAmountSum");
double productSum = safeGetDouble(o1, "productSum") + safeGetDouble(o2, "productSum");
double productTotalValueCountSum2 = safeGetDouble(o1, "productTotalValueCountSum2") + safeGetDouble(o2, "productTotalValueCountSum2");
double productTotalValueAmountSum2 = safeGetDouble(o1, "productTotalValueAmountSum2") + safeGetDouble(o2, "productTotalValueAmountSum2");
double productSum2 = safeGetDouble(o1, "productSum2") + safeGetDouble(o2, "productSum2");
double totalValueCountSum = safeGetDouble(o1, "totalValueCountSum") + safeGetDouble(o2, "totalValueCountSum");
double totalValueCountSum2 = safeGetDouble(o1, "totalValueCountSum2") + safeGetDouble(o2, "totalValueCountSum2");
double totalValueAmountSum = safeGetDouble(o1, "totalValueAmountSum") + safeGetDouble(o2, "totalValueAmountSum");
double totalValueAmountSum2 = safeGetDouble(o1, "totalValueAmountSum2") + safeGetDouble(o2, "totalValueAmountSum2");
double sum = safeGetDouble(o1, "sum") + safeGetDouble(o2, "sum");
double sum2 = safeGetDouble(o1, "sum2") + safeGetDouble(o2, "sum2");
List<Map> o1List = (ArrayList) o1.get("list");
List<Map> o2pList = (ArrayList) o2.get("list");
o1List.addAll(o2pList);
mergeMap.put("status", resultStatus);
mergeMap.put("apiTotalValueSum", String.format("%.2f", apiTotalValueSum));
mergeMap.put("apiSum", String.format("%.2f", apiSum));
mergeMap.put("apiTotalValueSum2", String.format("%.2f", apiTotalValueSum2));
mergeMap.put("apiSum2", String.format("%.2f", apiSum2));
mergeMap.put("productTotalValueCountSum", String.format("%.2f", productTotalValueCountSum));
mergeMap.put("productTotalValueAmountSum", String.format("%.2f", productTotalValueAmountSum));
mergeMap.put("productSum", String.format("%.2f", productSum));
mergeMap.put("productTotalValueCountSum2", String.format("%.2f", productTotalValueCountSum2));
mergeMap.put("productTotalValueAmountSum2", String.format("%.2f", productTotalValueAmountSum2));
mergeMap.put("productSum2", String.format("%.2f", productSum2));
mergeMap.put("totalValueCountSum", String.format("%.2f", totalValueCountSum));
mergeMap.put("totalValueCountSum2", String.format("%.2f", totalValueCountSum2));
mergeMap.put("totalValueAmountSum", String.format("%.2f", totalValueAmountSum));
mergeMap.put("totalValueAmountSum2", String.format("%.2f", totalValueAmountSum2));
mergeMap.put("sum", String.format("%.2f", sum));
mergeMap.put("sum2", String.format("%.2f", sum2));
mergeMap.put("list", o1List);
return mergeMap;
}
// ==================== Inner Classes ====================
/**
* 수수료 조회 결과를 담는 내부 클래스
*/
private static class CommissionResult {
private CommissionDTO data;
private String error;
static CommissionResult success(CommissionDTO data) {
CommissionResult result = new CommissionResult();
result.data = data;
return result;
}
static CommissionResult error(String message) {
CommissionResult result = new CommissionResult();
result.error = message;
return result;
}
boolean hasError() {
return error != null;
}
CommissionDTO getData() {
return data;
}
String getError() {
return error;
}
}
}
@@ -0,0 +1,28 @@
package com.eactive.apim.portal.apps.commission.dto;
import java.util.List;
import lombok.Data;
@Data
public class CommissionDTO {
private String status;
private String apiTotalValueSum;
private String apiSum;
private String apiTotalValueSum2;
private String apiSum2;
private String productTotalValueCountSum;
private String productTotalValueAmountSum;
private String productSum;
private String productTotalValueCountSum2;
private String productTotalValueAmountSum2;
private String productSum2;
private String totalValueCountSum;
private String totalValueCountSum2;
private String totalValueAmountSum;
private String totalValueAmountSum2;
private String sum;
private String sum2;
private List<CommissionList> list;
private String documentFormatNo;
}
@@ -0,0 +1,32 @@
package com.eactive.apim.portal.apps.commission.dto;
import lombok.Data;
/**
* Created by jskim on 17. 01. 26.
*
* @author jskim
*/
@Data
public class CommissionList {
private String paymentTargetName;
private String paymentTargetType;
private String value;
private String minimumAmount;
private String maximumAmount;
private String totalCount;
private String totalCount2;
private String totalValue;
private String totalValue2;
private String minimumAmount2;
private String maximumAmount2;
private String paymentBaseName;
private String paymentTimingName;
private String valueDivisionName;
private String sum;
private String sum2;
private String detailFeeTypeName;
private String value2;
private String changeReason2;
}
@@ -0,0 +1,17 @@
package com.eactive.apim.portal.apps.commission.dto;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@Data
public class CommissionSearch {
@ApiModelProperty(value = "대상년")
private String year;
@ApiModelProperty(value = "대상월")
private String month;
private String partnerCode;
}
@@ -0,0 +1,27 @@
package com.eactive.apim.portal.apps.commission.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
/**
* Created by ybsong on 16. 12. 7.
*
* @author ybsong
*/
public class ErrorUtil {
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message, String description) {
return new ResponseEntity<>(new ErrorVM(message, description), status);
}
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message) {
return create(status, message, null);
}
public static ResponseEntity<ErrorVM> create(String message, String description) {
return create(HttpStatus.BAD_REQUEST, message, description);
}
public static ResponseEntity<ErrorVM> create(String message) {
return create(message, null);
}
}
@@ -0,0 +1,43 @@
package com.eactive.apim.portal.apps.commission.exception;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* View Model for transferring error message with a list of field errors.
*/
@Data
public class ErrorVM implements Serializable {
private static final long serialVersionUID = 1L;
private final String message;
private final String description;
private List<FieldErrorVM> fieldErrors;
public ErrorVM(String message) {
this(message, null);
}
public ErrorVM(String message, String description) {
this.message = message;
this.description = description;
}
public ErrorVM(String message, String description, List<FieldErrorVM> fieldErrors) {
this.message = message;
this.description = description;
this.fieldErrors = fieldErrors;
}
public void add(String objectName, String field, String message) {
if (fieldErrors == null) {
fieldErrors = new ArrayList<>();
}
fieldErrors.add(new FieldErrorVM(objectName, field, message));
}
}
@@ -0,0 +1,33 @@
package com.eactive.apim.portal.apps.commission.exception;
import java.io.Serializable;
public class FieldErrorVM implements Serializable {
private static final long serialVersionUID = 1L;
private final String objectName;
private final String field;
private final String message;
public FieldErrorVM(String dto, String field, String message) {
this.objectName = dto;
this.field = field;
this.message = message;
}
public String getObjectName() {
return objectName;
}
public String getField() {
return field;
}
public String getMessage() {
return message;
}
}
@@ -0,0 +1,80 @@
package com.eactive.apim.portal.apps.commission.service;
import com.eactive.apim.portal.apps.commission.constant.Constants;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.Map;
import javax.inject.Inject;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
/**
* 외부 API 호출 서비스
* RestTemplate 기반으로 외부 시스템과 HTTP 통신을 수행합니다.
*
* @author ybsong
* @since 2017-03-02
*/
@Service
@Transactional
@RequiredArgsConstructor
public class ExternalService {
private final Logger log = LoggerFactory.getLogger(ExternalService.class);
private final RestTemplate restTemplate;
private final PortalPropertyService portalPropertyService;
/**
* HTTP POST 요청을 통해 외부 API를 호출하고 응답을 받습니다.
*
* @param url 호출할 API URL
* @param payload 요청 본문 (JSON 문자열)
* @return API 응답 본문 (문자열), 오류 발생 시 null
*/
public String getResponseHttpPost(String url, String payload) {
try {
Map<String, String> portalProperties = portalPropertyService.getPortalPropertiesAsMap("Portal");
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set(Constants.APIM_FOR_OBP_KEY, portalProperties.getOrDefault("obp.api_key","APIMPT-0002-QVBJTVBULTAwMDI="));
headers.set(Constants.APIM_CODE_FOR_OBP_KEY, portalProperties.getOrDefault("obp.partner_code","100001-01"));
// HTTP 요청 엔티티 생성 (헤더 + 본문)
HttpEntity<String> requestEntity = new HttpEntity<>(payload, headers);
// POST 요청 실행
ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
// 응답 본문 반환
return response.getBody();
} catch (HttpStatusCodeException e) {
// 4xx, 5xx 에러더라도 응답 본문이 있으면 반환 (에러 메시지 포함)
String responseBody = e.getResponseBodyAsString();
log.warn("HTTP error from external API: url={}, status={}, body={}", url, e.getStatusCode(), responseBody);
if (responseBody != null && !responseBody.isEmpty()) {
return responseBody;
}
return null;
} catch (RestClientException e) {
log.error("Failed to call external API: url={}, error={}", url, e.getMessage(), e);
return null;
} catch (Exception e) {
log.error("Unexpected error during HTTP POST: url={}", url, e);
return null;
}
}
}
@@ -1,8 +1,9 @@
package com.eactive.apim.portal.apps.community.partnershipapplication.controller;
package com.eactive.apim.portal.apps.community.partnership.controller;
import com.eactive.apim.portal.apps.community.partnershipapplication.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnershipapplication.service.PartnershipApplicationFacade;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.service.PartnershipApplicationFacade;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
@@ -38,13 +39,14 @@ public class PartnershipApplicationController {
}
model.addAttribute("partnership", new PartnershipApplicationDTO());
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
return "apps/community/mainPartnershipForm";
}
@PostMapping
public String createPartnershipApplication(@Valid @ModelAttribute PartnershipApplicationDTO partnershipApplicationDTO,
BindingResult bindingResult, HttpServletRequest httpServletRequest, RedirectAttributes redirectAttributes) throws IOException {
BindingResult bindingResult, HttpServletRequest httpServletRequest, RedirectAttributes redirectAttributes) throws IOException {
if (bindingResult.hasErrors()) {
return "apps/community/mainPartnershipForm";
}
@@ -1,4 +1,4 @@
package com.eactive.apim.portal.apps.community.partnershipapplication.dto;
package com.eactive.apim.portal.apps.community.partnership.dto;
import java.util.List;
import lombok.AllArgsConstructor;
@@ -1,7 +1,7 @@
package com.eactive.apim.portal.apps.community.partnershipapplication.mapper;
package com.eactive.apim.portal.apps.community.partnership.mapper;
import com.eactive.apim.portal.apps.community.partnershipapplication.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.common.mapper.CommonMapper;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import org.mapstruct.Mapper;
@@ -9,8 +9,8 @@ import org.mapstruct.ReportingPolicy;
import org.springframework.stereotype.Component;
@Mapper(componentModel = "spring",
uses = {CommonMapper.class},
unmappedTargetPolicy = ReportingPolicy.IGNORE)
uses = {CommonMapper.class},
unmappedTargetPolicy = ReportingPolicy.IGNORE)
@Component
public interface PartnershipApplicationMapper {
@@ -1,4 +1,4 @@
package com.eactive.apim.portal.apps.community.partnershipapplication.repository;
package com.eactive.apim.portal.apps.community.partnership.repository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.eai.rms.data.EMSDataSource;
@@ -1,6 +1,6 @@
package com.eactive.apim.portal.apps.community.partnershipapplication.service;
package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnershipapplication.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import java.io.IOException;
public interface PartnershipApplicationFacade {
@@ -1,7 +1,10 @@
package com.eactive.apim.portal.apps.community.partnershipapplication.service;
package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnershipapplication.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnershipapplication.mapper.PartnershipApplicationMapper;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipApplicationMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
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;
@@ -25,8 +28,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
FileInfo file = null;
if (!partnershipApplicationDTO.getFiles().isEmpty()) {
FileTypeContext.setFileType("business");
file = fileService.createFile(partnershipApplicationDTO.getFiles());
try {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
FileTypeContext.setFileType("business");
file = fileService.createFile(partnershipApplicationDTO.getFiles());
} finally {
FileService.clearInternalUserContext();
}
}
PartnershipApplication partnershipApplication = partnershipApplicationMapper.toEntity(partnershipApplicationDTO);
if (file != null) {
@@ -1,6 +1,6 @@
package com.eactive.apim.portal.apps.community.partnershipapplication.service;
package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnershipapplication.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -5,9 +5,9 @@ import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
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 java.io.IOException;
import com.eactive.apim.portal.file.exception.InvalidFileException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
@@ -68,6 +68,7 @@ public class InquiryController {
@GetMapping("/new")
public String newInquiryForm(Model model) {
model.addAttribute("inquiry", new InquiryDTO());
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
@@ -78,6 +79,7 @@ public class InquiryController {
public String editInquiryForm(@RequestParam("id") String id, Model model) {
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
model.addAttribute("inquiry", inquiry);
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
@@ -123,11 +125,4 @@ public class InquiryController {
redirectAttributes.addFlashAttribute("success", "삭제 되었습니다.");
return REDIRECT_INQUIRY;
}
@ExceptionHandler(InvalidFileException.class)
public String handleInvalidFileException(InvalidFileException e, Model model) {
model.addAttribute("inquiry", new InquiryDTO());
model.addAttribute("error", e.getMessage());
return "redirect:/inquiry/new";
}
}
@@ -6,6 +6,7 @@ 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;
@@ -83,8 +84,15 @@ public class InquiryFacadeImpl implements InquiryFacade {
FileInfo file = null;
if (!inquiryDTO.getFiles().isEmpty()) {
FileTypeContext.setFileType("qna");
file = fileService.createFile(inquiryDTO.getFiles());
try {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
FileTypeContext.setFileType("qna");
file = fileService.createFile(inquiryDTO.getFiles());
} finally {
FileService.clearInternalUserContext();
}
}
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
@@ -104,12 +112,17 @@ public class InquiryFacadeImpl implements InquiryFacade {
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()) {
FileTypeContext.setFileType("qna");
FileInfo file = fileService.createFile(inquiryDTO.getFiles());
if(file != null) {
inquiryDTO.setAttachFile(file.getFileId());
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());
@@ -4,7 +4,6 @@ import com.eactive.apim.portal.apps.dashboard.service.DashboardService;
import com.eactive.apim.portal.common.pagerouter.PageHandler;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -22,9 +21,7 @@ public class DashboardHandler implements PageHandler {
if (SecurityUtil.getPortalAuthenticatedUser() != null && SecurityUtil.getPortalAuthenticatedUser().getPortalOrg() != null) {
PortalOrg org = SecurityUtil.getPortalAuthenticatedUser().getPortalOrg();
model.addAttribute("pendingKeyCount", dashboardService.getPendingApiKeyRequestCount(org));
model.addAttribute("testKeyCount", dashboardService.getTestApiKeyCount(org));
model.addAttribute("pendingProdCount", dashboardService.getProdApiKeyRequestCount(org));
model.addAttribute("prodKeyCount", dashboardService.getProdApiKeyCount(org));
model.addAttribute("testKeyCount", dashboardService.getApiKeyCount(org));
model.addAttribute("totalApi", dashboardService.getTotalApi(org));
model.addAttribute("dailyTotal", dashboardService.getDailyTotalApiUsage(org));
@@ -5,24 +5,18 @@ import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.ApprovalStatus;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.dashboard.ApiDailyStatistics;
import com.eactive.apim.portal.apps.dashboard.repository.PortalStatisticsRepository;
import com.eactive.apim.portal.apps.dashboard.repository.PortalStatisticsRepository.TopApiUsage;
import com.eactive.apim.portal.common.util.DateUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -39,15 +33,12 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class DashboardService {
private final static String PROD_SERVER = "PROD";
private final static String STG_SERVER = "STG";
private final AppRequestRepository appRequestRepository;
private final CredentialRepository credentialRepository;
private final PortalStatisticsRepository portalStatisticsRepository;
public int getTestApiKeyCount(PortalOrg org) {
return credentialRepository.countAllByOrgidAndServer(org.getId(), STG_SERVER);
public int getApiKeyCount(PortalOrg org) {
return credentialRepository.countAllByOrgid(org.getId());
}
public int getPendingApiKeyRequestCount(PortalOrg org) {
@@ -55,18 +46,9 @@ public class DashboardService {
Arrays.asList(new ProcessingState(), new RequestedState()));
}
public int getProdApiKeyRequestCount(PortalOrg org) {
return appRequestRepository.countAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(org, Arrays.asList(AppRequestType.PROD_NEW, AppRequestType.PROD_MODIFY, AppRequestType.PROD_DELETE),
Arrays.asList(new ProcessingState(), new RequestedState()));
}
public int getProdApiKeyCount(PortalOrg org) {
return credentialRepository.findAllByOrgidAndServer(org.getId(), PROD_SERVER).size();
}
public int getTotalApi(PortalOrg org) {
List<Credential> credentials = credentialRepository.findAllByOrgidAndServer(org.getId(), PROD_SERVER);
List<Credential> credentials = credentialRepository.findAllByOrgid(org.getId());
return (int) credentials.stream()
.flatMap(credential -> credential.getApiList().stream())
.map(ApiSpecInfo::getApiId)
@@ -39,4 +39,54 @@ public class FileDownloadController {
IOUtils.write(fileContents, response.getOutputStream());
response.flushBuffer();
}
/**
* 이미지 인라인 표시용 (브라우저에서 직접 렌더링)
* 약관 등에서 이미지를 표시할 때 사용
*/
@GetMapping(value = "/view")
public void viewImage(@RequestParam(value = "fileId") String fileId,
@RequestParam(value = "fileSn", defaultValue = "1") int fileSn,
HttpServletResponse response) throws IOException {
FileDetail fileDetail = fileService.getFileDetailByIds(fileId, fileSn);
// Content-Type 설정 (이미지용)
String contentType = getImageContentType(fileDetail.getFileExtension());
response.setContentType(contentType);
response.setContentLength(fileDetail.getFileSize());
// inline으로 설정 (브라우저에서 직접 표시)
response.setHeader("Content-Disposition", "inline");
// 캐시 설정 (1일)
response.setHeader("Cache-Control", "public, max-age=86400");
byte[] fileContents = fileService.retrieveFileContent(fileDetail);
IOUtils.write(fileContents, response.getOutputStream());
response.flushBuffer();
}
private String getImageContentType(String extension) {
if (extension == null) {
return "application/octet-stream";
}
switch (extension.toLowerCase()) {
case "jpg":
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
case "bmp":
return "image/bmp";
case "webp":
return "image/webp";
case "svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
}
}
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -79,7 +80,7 @@ public class AccountRecoveryController {
}
try {
PortalUserDTO foundId = portalUserAuthService.findUsersByNameAndMobile(findIdDTO.getUserName(), findIdDTO.getMobileNumber());
List<PortalUserDTO> foundUsers = portalUserAuthService.findAllUsersByNameAndMobile(findIdDTO.getUserName(), findIdDTO.getMobileNumber());
// 인증 관련 세션 정보 제거
session.removeAttribute("mobileNumber");
@@ -87,7 +88,7 @@ public class AccountRecoveryController {
//redirect 가 아닌 경우에는 model 을 사용해서 페이지 렌더링 처리
model.addAttribute("findIdDTO", findIdDTO);
model.addAttribute("foundId", foundId);
model.addAttribute("foundUsers", foundUsers);
return ACCOUNT_RECOVERY_RESULT;
} catch (UserNotFoundException e) {
@@ -112,14 +113,23 @@ public class AccountRecoveryController {
// 세션에서 인증 여부 확인
Boolean isVerified = (Boolean) session.getAttribute("isVerified");
String sessionMobileNumber = (String) session.getAttribute("mobileNumber");
if (isVerified == null || !isVerified) {
setModelForError(redirectAttributes, "resetPassword", null, "휴대폰 번호 인증을 먼저 진행해주세요.", null, passwordReset);
return "redirect:/account_recovery?tab=resetPassword";
}
// 인증된 전화번호와 입력된 전화번호 일치 여부 확인
if (passwordReset.getMobileNumber() == null ||
!passwordReset.getMobileNumber().replace("-", "").equals(sessionMobileNumber)) {
setModelForError(redirectAttributes, "resetPassword", null, "인증된 휴대폰 번호와 입력한 번호가 일치하지 않습니다.", null, passwordReset);
return "redirect:/account_recovery?tab=resetPassword";
}
try {
// 사용자 정보 확인
portalUserAuthService.initializePassword(
portalUserAuthService.resetPassword(
passwordReset.getLoginId(),
passwordReset.getUserName(),
passwordReset.getMobileNumber()
@@ -1,37 +1,27 @@
package com.eactive.apim.portal.apps.main.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceApiDTO;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
import com.eactive.apim.portal.apps.community.notice.service.PortalNoticeFacade;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.main.service.IndexStatisticsService;
import com.eactive.apim.portal.apps.main.service.MainApiFacade;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.*;
import java.util.stream.Collectors;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequiredArgsConstructor
public class IndexController {
private final ApiSearchFacade apiSearchFacade;
private final MainApiFacade mainApiFacade;
private final PortalNoticeFacade portalNoticeFacade;
private final IndexStatisticsService indexStatisticsService;
/**
* 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함.
* 프로퍼티에 등록되어 있어도 어드민의 그룹 표시 설정이 N이면 표시 되지 않음.
* 어드민의 API 스펙의 표시 설정이 N이면 표시되지 않음.
* 그룹 또는 권한이 지정된 경우, 해당사용자의 권한 또는 기관이 일치해야 표시됨.
* 어드민의 API 스펙의 표시 설정이 N이면 표시되지 않음. 그룹 또는 권한이 지정된 경우, 해당사용자의 권한 또는 기관이 일치해야 표시됨.
* <p>
* 검색어는 Portal Property 또는 메인 관리 화면에 main.keyword.list 에 등록된 내용이 표시 됨.
*
@@ -40,147 +30,28 @@ public class IndexController {
*/
@GetMapping("/")
public String index(Model model) {
// Step 1: Fetch and sort API services
List<ApiServiceDTO> apiServices = getSortedApiServices(null);
// Step 2: Create a map of API ID to ApiServiceDTO for icon mapping
Map<String, ApiServiceDTO> mainIconsMap = createMainIconsMap(apiServices);
// Step 3: Merge all API lists from apiServices
List<ApiServiceApiDTO> allApis = getAllApis(apiServices);
// Step 4: Enrich API spec info DTOs with main icon and service info
List<ApiSpecInfoDto> apiSpecInfoDtos = enrichApiSpecInfo(apiServices, allApis, mainIconsMap);
// Step 5: Calculate total API count
int totalApiCount = calculateTotalApiCount(apiServices);
// Step 6: Fetch portal notices and hashtags
List<PortalNoticeDTO> portalNotices = portalNoticeFacade.getLatestNotices();
List<ApiServiceDTO> services = mainApiFacade.getOpenApiServices();
List<String> hashTags = mainApiFacade.getHashTags();
ApiGroupSearch search = new ApiGroupSearch();
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
// 인덱스 페이지 하단 통계 조회 (캐싱 적용)
IndexStatisticsDTO statistics = indexStatisticsService.getStatistics();
int selectedApiCount = (int) searchResult.get("selectedApiCount");
// 서비스 이용 수
int serviceCount = ((List<?>) searchResult.get("services")).size();
// 승인된 기업 건수
int approvedOrgCount = mainApiFacade.getApprovedOrgCount();
// Step 7: Add attributes to model
addAttributesToModel(model, apiServices, apiSearchFacade.sortApisByService(apiSpecInfoDtos, apiServices), totalApiCount, portalNotices, hashTags, serviceCount, approvedOrgCount, selectedApiCount);
addAttributesToModel(model, services, hashTags, statistics);
return "apps/main/index";
}
private List<ApiServiceDTO> getSortedApiServices(String apiId) {
List<ApiServiceDTO> apiServices = mainApiFacade.getOpenApiServices(apiId);
apiServices.sort(Comparator.comparing(ApiServiceDTO::getDisplayOrder));
return apiServices;
}
private Map<String, ApiServiceDTO> createMainIconsMap(List<ApiServiceDTO> apiServices) {
Map<String, ApiServiceDTO> mainIconsMap = new HashMap<>();
apiServices.forEach(service ->
service.getApiGroupApiList().forEach(api ->
mainIconsMap.put(api.getApiId(), service)
)
);
return mainIconsMap;
}
private List<ApiServiceApiDTO> getAllApis(List<ApiServiceDTO> apiServices) {
return apiServices.stream()
.flatMap(service -> service.getApiGroupApiList().stream())
.collect(Collectors.toList());
}
private List<ApiSpecInfoDto> enrichApiSpecInfo(List<ApiServiceDTO> services, List<ApiServiceApiDTO> allApis, Map<String, ApiServiceDTO> mainIconsMap) {
List<String> apiIds = allApis.stream().map(ApiServiceApiDTO::getApiId).collect(Collectors.toList());
List<ApiSpecInfoDto> apiSpecInfoDtos = mainApiFacade.getOpenApis(apiIds);
apiSpecInfoDtos.forEach(spec -> {
ApiServiceDTO serviceDTO = mainIconsMap.get(spec.getApiId());
if (serviceDTO != null) {
spec.setMainIcon(serviceDTO.getMainIcon());
spec.setService(serviceDTO.getGroupName());
}
});
boolean isAuthenticated = SecurityUtil.isAuthenticated();
String roleCode;
String org;
if (isAuthenticated) {
roleCode = SecurityUtil.getPortalAuthenticatedUser().getRoleCode().toString();
org = SecurityUtil.getUserOrg().getId();
} else {
roleCode = null;
org = null;
}
List<ApiSpecInfoDto> filteredApiSpecInfoDtos = apiSpecInfoDtos.stream()
.filter(spec -> {
if (!isAuthenticated) {
return spec.getDisplayOrg() == null && spec.getDisplayRoleCode() == null;
}
boolean orgMatch = false;
if (org != null) {
orgMatch = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty() || spec.getDisplayOrg().contains(org);
}
boolean roleMatch = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty() || spec.getDisplayRoleCode().contains(roleCode);
return orgMatch || roleMatch;
})
.collect(Collectors.toList());
Set<String> filteredApiIds = filteredApiSpecInfoDtos.stream()
.map(ApiSpecInfoDto::getApiId)
.collect(Collectors.toSet());
services.forEach(service -> {
if (service.getApiGroupApiList() != null) {
List<ApiServiceApiDTO> filteredApiList = service.getApiGroupApiList().stream()
.filter(api -> filteredApiIds.contains(api.getApiId()))
.collect(Collectors.toList());
service.setApiGroupApiList(filteredApiList);
}
});
return filteredApiSpecInfoDtos.stream().collect(Collectors.toList());
}
private int calculateTotalApiCount(List<ApiServiceDTO> apiServices) {
return apiServices.stream()
.mapToInt(service -> service.getApiGroupApiList().size())
.sum();
}
private void addAttributesToModel(Model model, List<ApiServiceDTO> apiServices,
List<ApiSpecInfoDto> apiSpecInfoDtos, int totalApiCount,
List<PortalNoticeDTO> portalNotices, List<String> hashTags, int serviceCount, int approvedOrgCount, int selectedApiCount) {
private void addAttributesToModel(Model model, List<ApiServiceDTO> apiServices, List<String> hashTags, IndexStatisticsDTO statistics) {
model.addAttribute("services", apiServices);
model.addAttribute("apis", apiSpecInfoDtos);
model.addAttribute("totalApiCount", totalApiCount);
model.addAttribute("portalNotices", portalNotices);
model.addAttribute("hashtags", hashTags);
model.addAttribute("serviceCount", serviceCount);
model.addAttribute("approvedOrgCount", approvedOrgCount);
model.addAttribute("selectedApiCount", selectedApiCount);
// 인덱스 페이지 하단 통계
model.addAttribute("statisticsVisible", statistics.isVisible());
model.addAttribute("approvedOrgCount", statistics.getApprovedOrgCount());
model.addAttribute("serviceCount", statistics.getActiveAppCount());
model.addAttribute("totalApiCount", statistics.getTotalApiCount());
}
@GetMapping("/api_fragments")
public String getApiFragments(@RequestParam(value = "id", required = false) String id, Model model) {
List<ApiServiceDTO> apiServices = getSortedApiServices(id);
Map<String, ApiServiceDTO> mainIconsMap = createMainIconsMap(apiServices);
List<ApiServiceApiDTO> allApis = getAllApis(apiServices);
List<ApiSpecInfoDto> apiSpecInfoDtos = enrichApiSpecInfo(apiServices, allApis, mainIconsMap);
model.addAttribute("apis", apiSpecInfoDtos);
return "apps/main/apiListFragment :: apiListFragment";
}
}
@@ -0,0 +1,44 @@
package com.eactive.apim.portal.apps.main.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 인덱스 페이지 하단 통계 DTO
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IndexStatisticsDTO {
/**
* 통계 섹션 노출 여부
* PortalProperty의 main.statistics.display 값으로 설정 (기본값: true)
*/
@Builder.Default
private boolean visible = true;
/**
* API 활용 기업 수
* 법인으로 등록되고 정상 상태인 기업의 수
*/
private int approvedOrgCount;
/**
* 서비스 이용 수
* 정상 상태 법인이 생성한 앱 중 이용 가능 상태인 앱의 수
*/
private int activeAppCount;
/**
* API 이용 건수
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총 수
*/
private int totalApiCount;
}
@@ -0,0 +1,161 @@
package com.eactive.apim.portal.apps.main.service;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 인덱스 페이지 통계 서비스
* 메모리 캐싱을 통해 DB 조회를 최소화 (1시간 TTL)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IndexStatisticsService {
private static final long CACHE_TTL_HOURS = 1;
private static final String APP_STATUS_ACTIVE = "1";
private static final String PORTAL_PROPERTY_GROUP = "Portal";
private static final String STATISTICS_DISPLAY_KEY = "main.statistics.display";
private static final String DISPLAY_YES = "Y";
private final PortalOrgRepository portalOrgRepository;
private final CredentialRepository credentialRepository;
private final PortalPropertyService portalPropertyService;
// 캐시된 통계 데이터
private volatile IndexStatisticsDTO cachedStatistics;
// 마지막 집계 시간
private volatile LocalDateTime lastAggregatedTime;
/**
* 인덱스 페이지 통계 조회
* 캐시가 없거나 1시간이 경과한 경우 재집계
*/
@Transactional(readOnly = true)
public IndexStatisticsDTO getStatistics() {
if (shouldRefreshCache()) {
synchronized (this) {
// Double-check locking
if (shouldRefreshCache()) {
refreshStatistics();
}
}
}
return cachedStatistics;
}
/**
* 캐시 갱신이 필요한지 확인
*/
private boolean shouldRefreshCache() {
if (cachedStatistics == null || lastAggregatedTime == null) {
return true;
}
return LocalDateTime.now().isAfter(lastAggregatedTime.plusHours(CACHE_TTL_HOURS));
}
/**
* 통계 재집계
*/
private void refreshStatistics() {
log.info("인덱스 페이지 통계 집계 시작");
long startTime = System.currentTimeMillis();
try {
// 0. 노출 여부 확인 (PortalProperty에서 조회, 기본값: Y)
boolean visible = isStatisticsVisible();
// 1. API 활용 기업 수: 정상 상태이고 승인 완료된 기관 수
int approvedOrgCount = (int) portalOrgRepository.countByOrgStatusAndApprovalStatus(
OrgStatus.ACTIVE, ApprovalStatus.COMPLETED);
// 2. 정상 상태 기관의 ID 목록 조회
List<String> activeOrgIds = portalOrgRepository.findIdsByOrgStatusAndApprovalStatus(
OrgStatus.ACTIVE, ApprovalStatus.COMPLETED);
int activeAppCount = 0;
int totalApiCount = 0;
if (!activeOrgIds.isEmpty()) {
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API 수
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds);
totalApiCount = activeApps.stream()
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0)
.sum();
}
cachedStatistics = IndexStatisticsDTO.builder()
.visible(visible)
.approvedOrgCount(approvedOrgCount)
.activeAppCount(activeAppCount)
.totalApiCount(totalApiCount)
.build();
lastAggregatedTime = LocalDateTime.now();
long elapsed = System.currentTimeMillis() - startTime;
log.info("인덱스 페이지 통계 집계 완료 - 소요시간: {}ms, 노출: {}, 기업: {}, 앱: {}, API: {}",
elapsed, visible, approvedOrgCount, activeAppCount, totalApiCount);
} catch (Exception e) {
log.error("인덱스 페이지 통계 집계 실패", e);
// 기존 캐시가 없는 경우 기본값 설정
if (cachedStatistics == null) {
cachedStatistics = IndexStatisticsDTO.builder()
.visible(true)
.approvedOrgCount(0)
.activeAppCount(0)
.totalApiCount(0)
.build();
}
}
}
/**
* PortalProperty에서 통계 노출 여부 조회
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
*/
private boolean isStatisticsVisible() {
String displayValue = portalPropertyService.getOrCreateProperty(
PORTAL_PROPERTY_GROUP,
STATISTICS_DISPLAY_KEY,
DISPLAY_YES,
"인덱스 페이지 하단 통계 섹션 노출 여부 (Y: 노출, N: 숨김)"
);
return DISPLAY_YES.equalsIgnoreCase(displayValue);
}
/**
* 캐시 강제 갱신 (관리자용)
*/
public void forceRefresh() {
synchronized (this) {
lastAggregatedTime = null;
refreshStatistics();
}
}
/**
* 캐시 만료 시간 조회
*/
public LocalDateTime getCacheExpireTime() {
if (lastAggregatedTime == null) {
return null;
}
return lastAggregatedTime.plusHours(CACHE_TTL_HOURS);
}
}
@@ -7,17 +7,13 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.user.service.PortalOrgService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@Transactional
@Service
@@ -34,17 +30,13 @@ public class MainApiFacade {
return portalOrgService.getActiveApprovedOrgCount();
}
public List<ApiServiceDTO> getOpenApiServices(String serviceId) {
public List<ApiServiceDTO> getOpenApiServices() {
ApiGroupSearch search = new ApiGroupSearch();
Map<String, String> property = portalPropertyService.getPortalPropertiesAsMap(PORTAL_PROPERTY);
String services = property.getOrDefault("main.service.list", "");
if (StringUtils.hasText(serviceId) && services.contains(serviceId)) {
search.setGroupIds(Collections.singletonList(serviceId));
} else {
search.setGroupIds(Arrays.asList(services.split(",")));
}
search.setGroupIds(Arrays.asList(services.replace(" ", "").split(",")));
return apiServiceService.searchApiGroups(search);
}
@@ -1,146 +0,0 @@
package com.eactive.apim.portal.apps.proxy;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@RestController
@RequestMapping("/_proxy") // /proxy 대신 /_proxy 사용
@Slf4j
public class ApimProxyController {
private final RestTemplate restTemplate;
private final ProxyProperties proxyProperties;
private final ObjectMapper objectMapper;
private final MediaType JSON_UTF8 = new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8);
public ApimProxyController(
RestTemplateBuilder restTemplateBuilder,
ProxyProperties proxyProperties,
ObjectMapper objectMapper) {
this.proxyProperties = proxyProperties;
this.objectMapper = objectMapper;
this.restTemplate = restTemplateBuilder
.setConnectTimeout(Duration.ofMillis(proxyProperties.getTimeout().getConnect()))
.setReadTimeout(Duration.ofMillis(proxyProperties.getTimeout().getRead()))
.build();
}
private ResponseEntity<String> createJsonResponse(HttpStatus status, String message) {
ObjectNode jsonResponse = objectMapper.createObjectNode()
.put("status", status.value())
.put("message", message);
return ResponseEntity
.status(status)
.contentType(JSON_UTF8)
.body(jsonResponse.toString());
}
@RequestMapping(value = "/**")
public ResponseEntity<String> proxyRequest(
HttpServletRequest request,
@RequestBody(required = false) String body,
@RequestHeader HttpHeaders headers) {
String targetKey = headers.getFirst("target_host");
if (targetKey == null || targetKey.trim().isEmpty()) {
log.error("Missing or empty target_host header");
return createJsonResponse(HttpStatus.BAD_REQUEST, "Error: target_host header is required");
}
String targetUrl = proxyProperties.getTargets().get(targetKey.trim().toLowerCase());
if (targetUrl == null) {
log.error("Invalid target_host: {}. Available targets: {}",
targetKey, proxyProperties.getTargets().keySet());
return createJsonResponse(HttpStatus.BAD_REQUEST,
"Error: Invalid target_host. Must be one of: " +
String.join(", ", proxyProperties.getTargets().keySet()));
}
try {
String requestUrl = request.getRequestURI().replace("/_proxy", "");
URI uri = UriComponentsBuilder
.fromHttpUrl(targetUrl + requestUrl)
.query(request.getQueryString())
.build()
.toUri();
HttpMethod method = HttpMethod.valueOf(request.getMethod());
HttpHeaders proxyHeaders = new HttpHeaders();
headers.forEach((key, value) -> {
if (!key.equalsIgnoreCase("host") &&
!key.equalsIgnoreCase("target_host")) {
proxyHeaders.addAll(key, value);
}
});
HttpEntity<String> httpEntity = new HttpEntity<>(body, proxyHeaders);
log.debug("Forwarding request to: {} ({})", targetKey, uri);
ResponseEntity<String> response = restTemplate.exchange(
uri,
method,
httpEntity,
String.class
);
log.debug("Received response status: {}", response.getStatusCode());
// Create JSON response with the original response data
ObjectNode jsonResponse = objectMapper.createObjectNode()
.put("status", response.getStatusCode().value());
// Try to parse the response body as JSON, if it fails, include it as a string
try {
jsonResponse.set("data", objectMapper.readTree(response.getBody()));
} catch (Exception e) {
jsonResponse.put("data", response.getBody());
}
return ResponseEntity
.status(response.getStatusCode())
.contentType(JSON_UTF8)
.body(jsonResponse.toString());
} catch (HttpStatusCodeException e) {
log.error("Target server returned error: {} for host: {}", e.getStatusCode(), targetKey);
ObjectNode jsonResponse = objectMapper.createObjectNode()
.put("status", e.getStatusCode().value());
// Try to parse the error response body as JSON, if it fails, include it as a string
try {
jsonResponse.set("data", objectMapper.readTree(e.getResponseBodyAsString()));
} catch (Exception ex) {
jsonResponse.put("data", e.getResponseBodyAsString());
}
return ResponseEntity
.status(e.getStatusCode())
.contentType(JSON_UTF8)
.body(jsonResponse.toString());
} catch (Exception e) {
log.error("Error during proxy request to: {}", targetKey, e);
return createJsonResponse(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
}
}
}
@@ -1,23 +0,0 @@
package com.eactive.apim.portal.apps.proxy;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "proxy")
@Getter
@Setter
public class ProxyProperties {
private Map<String, String> targets;
private Timeout timeout = new Timeout();
@Getter
@Setter
public static class Timeout {
private int connect = 5000;
private int read = 5000;
}
}
@@ -0,0 +1,112 @@
package com.eactive.apim.portal.apps.statistics.controller;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSearchDto;
import com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.io.IOException;
import java.time.LocalDate;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
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.ResponseBody;
/**
* API 통계 Controller
*/
@Slf4j
@Controller
@RequestMapping("/statistics/api")
@RequiredArgsConstructor
public class ApiStatisticsController {
private final ApiStatisticsService apiStatisticsService;
/**
* 통계 페이지 진입
*/
@GetMapping
public String statisticsPage(Model model) {
PortalOrg org = getPortalOrg();
if (org == null) {
return "redirect:/login";
}
String orgId = org.getId();
// 앱 목록 조회 (선택용)
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
// 기본 조회 (7일 전 ~ 오늘, 전체 앱)
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
searchDto.setStartDate(LocalDate.now().minusDays(7));
searchDto.setEndDate(LocalDate.now());
searchDto.setClientId(null);
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
model.addAttribute("summary", result.getSummary());
model.addAttribute("details", result.getDetails());
model.addAttribute("searchDto", searchDto);
return "apps/statistics/apiStatistics";
}
/**
* 통계 조회 (AJAX)
*/
@PostMapping("/search")
@ResponseBody
public ResponseEntity<?> searchStatistics(@RequestBody ApiStatisticsSearchDto searchDto) {
PortalOrg org = getPortalOrg();
if (org == null) {
return ResponseEntity.status(401).build();
}
// 날짜 범위 검증 (최대 40일)
if (!searchDto.isValidDateRange()) {
return ResponseEntity.badRequest()
.body(java.util.Collections.singletonMap("error",
"조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다."));
}
String orgId = org.getId();
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
return ResponseEntity.ok(result);
}
/**
* CSV 다운로드
*/
@GetMapping("/download")
public void downloadCsv(ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
PortalOrg org = getPortalOrg();
if (org == null) {
response.sendError(401, "Unauthorized");
return;
}
// 날짜 범위 검증 (최대 40일)
if (!searchDto.isValidDateRange()) {
response.sendError(400, "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.");
return;
}
String orgId = org.getId();
apiStatisticsService.downloadCsv(orgId, searchDto, response);
}
private PortalOrg getPortalOrg() {
if (SecurityUtil.getPortalAuthenticatedUser() != null) {
return SecurityUtil.getPortalAuthenticatedUser().getPortalOrg();
}
return null;
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.statistics.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 개별 API 통계 상세 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiStatisticsDetailDto {
private String apiId;
private String apiName;
private Long attemptedCount; // 호출 건
private Long completedCount; // 성공 건
private Long failedCount; // 실패 건
private Double successRate; // 성공율 (%)
private Double failureRate; // 실패율 (%)
/**
* Repository 조회용 생성자
*/
public ApiStatisticsDetailDto(String apiId, String apiName, Long attemptedCount, Long completedCount) {
this.apiId = apiId;
this.apiName = apiName;
this.attemptedCount = attemptedCount != null ? attemptedCount : 0L;
this.completedCount = completedCount != null ? completedCount : 0L;
}
/**
* 실패건, 성공률, 실패율 계산
*/
public void calculateRates() {
if (attemptedCount == null) {
attemptedCount = 0L;
}
if (completedCount == null) {
completedCount = 0L;
}
this.failedCount = attemptedCount - completedCount;
if (attemptedCount > 0) {
this.successRate = Math.round((completedCount * 1000.0) / attemptedCount) / 10.0;
this.failureRate = Math.round((failedCount * 1000.0) / attemptedCount) / 10.0;
} else {
this.successRate = 0.0;
this.failureRate = 0.0;
}
}
}
@@ -0,0 +1,29 @@
package com.eactive.apim.portal.apps.statistics.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* API 통계 조회 결과 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiStatisticsResultDto {
private ApiStatisticsSummaryDto summary;
private List<ApiStatisticsDetailDto> details;
/**
* 빈 결과 생성
*/
public static ApiStatisticsResultDto empty() {
return new ApiStatisticsResultDto(
ApiStatisticsSummaryDto.empty(),
new ArrayList<>()
);
}
}
@@ -0,0 +1,66 @@
package com.eactive.apim.portal.apps.statistics.dto;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
/**
* API 통계 검색 조건 DTO
*/
@Data
public class ApiStatisticsSearchDto {
/**
* 최대 조회 가능 일수
*/
public static final int MAX_DATE_RANGE_DAYS = 40;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;
/**
* 선택된 앱의 clientId (null 또는 빈값이면 전체 앱)
*/
private String clientId;
public ApiStatisticsSearchDto() {
this.startDate = LocalDate.now().minusDays(7);
this.endDate = LocalDate.now();
this.clientId = null;
}
/**
* 특정 앱이 선택되었는지 여부
*/
public boolean hasSelectedApp() {
return clientId != null && !clientId.trim().isEmpty();
}
/**
* 날짜 범위가 유효한지 검증 (최대 40일)
*/
public boolean isValidDateRange() {
if (startDate == null || endDate == null) {
return false;
}
if (startDate.isAfter(endDate)) {
return false;
}
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
return daysBetween <= MAX_DATE_RANGE_DAYS;
}
/**
* 조회 기간 일수 반환
*/
public long getDateRangeDays() {
if (startDate == null || endDate == null) {
return 0;
}
return ChronoUnit.DAYS.between(startDate, endDate);
}
}
@@ -0,0 +1,59 @@
package com.eactive.apim.portal.apps.statistics.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* API 전체 통계 요약 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiStatisticsSummaryDto {
private Long totalAttempted; // 전체 호출 건
private Long totalCompleted; // 전체 성공 건
private Long totalFailed; // 전체 실패 건
private Double successRate; // 성공율 (%)
private Double failureRate; // 실패율 (%)
/**
* Repository 조회용 생성자 (attempted, completed만)
*/
public ApiStatisticsSummaryDto(Long totalAttempted, Long totalCompleted) {
this.totalAttempted = totalAttempted != null ? totalAttempted : 0L;
this.totalCompleted = totalCompleted != null ? totalCompleted : 0L;
}
/**
* 실패건, 성공률, 실패율 계산
*/
public void calculateRates() {
if (totalAttempted == null) {
totalAttempted = 0L;
}
if (totalCompleted == null) {
totalCompleted = 0L;
}
this.totalFailed = totalAttempted - totalCompleted;
if (totalAttempted > 0) {
this.successRate = Math.round((totalCompleted * 1000.0) / totalAttempted) / 10.0;
this.failureRate = Math.round((totalFailed * 1000.0) / totalAttempted) / 10.0;
} else {
this.successRate = 0.0;
this.failureRate = 0.0;
}
}
/**
* 빈 통계 생성
*/
public static ApiStatisticsSummaryDto empty() {
ApiStatisticsSummaryDto dto = new ApiStatisticsSummaryDto(0L, 0L);
dto.calculateRates();
return dto;
}
}
@@ -0,0 +1,81 @@
package com.eactive.apim.portal.apps.statistics.repository;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
import com.eactive.apim.portal.obp.entity.ObpGwMetric;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* API 통계 Repository
* ObpGwMetric 테이블에서 통계 데이터를 조회
*/
@EMSDataSource
public interface ApiStatisticsRepository extends BaseRepository<ObpGwMetric, String> {
/**
* 전체 통계 조회 (특정 기간, 특정 조직의 모든 앱)
*/
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
"FROM ObpGwMetric m " +
"WHERE m.orgId = :orgId " +
"AND m.timeslice BETWEEN :startTime AND :endTime")
ApiStatisticsSummaryDto findSummaryByOrgAndPeriod(
@Param("orgId") String orgId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 전체 통계 조회 (특정 기간, 특정 앱)
*/
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
"FROM ObpGwMetric m " +
"WHERE m.orgId = :orgId " +
"AND m.clientId = :clientId " +
"AND m.timeslice BETWEEN :startTime AND :endTime")
ApiStatisticsSummaryDto findSummaryByOrgAndApp(
@Param("orgId") String orgId,
@Param("clientId") String clientId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 개별 API 통계 조회 (특정 기간, 특정 조직의 모든 앱)
*/
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
"m.apiId, m.apiName, " +
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
"FROM ObpGwMetric m " +
"WHERE m.orgId = :orgId " +
"AND m.timeslice BETWEEN :startTime AND :endTime " +
"GROUP BY m.apiId, m.apiName " +
"ORDER BY SUM(m.attemptedCount) DESC")
List<ApiStatisticsDetailDto> findDetailByOrgAndPeriod(
@Param("orgId") String orgId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 개별 API 통계 조회 (특정 기간, 특정 앱)
*/
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
"m.apiId, m.apiName, " +
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
"FROM ObpGwMetric m " +
"WHERE m.orgId = :orgId " +
"AND m.clientId = :clientId " +
"AND m.timeslice BETWEEN :startTime AND :endTime " +
"GROUP BY m.apiId, m.apiName " +
"ORDER BY SUM(m.attemptedCount) DESC")
List<ApiStatisticsDetailDto> findDetailByOrgAndApp(
@Param("orgId") String orgId,
@Param("clientId") String clientId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
}
@@ -0,0 +1,172 @@
package com.eactive.apim.portal.apps.statistics.service;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSearchDto;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
import com.eactive.apim.portal.apps.statistics.repository.ApiStatisticsRepository;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Collections;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* API 통계 Service
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatisticsService {
private final CredentialRepository credentialRepository;
private final ApiStatisticsRepository apiStatisticsRepository;
/**
* 로그인 사용자 법인의 앱 목록 조회 (전체)
*/
public List<Credential> getAppListByOrg(String orgId) {
return credentialRepository.findAllByOrgid(orgId);
}
/**
* 통계 조회
*/
public ApiStatisticsResultDto getStatistics(String orgId, ApiStatisticsSearchDto searchDto) {
LocalDateTime startTime = searchDto.getStartDate().atStartOfDay();
LocalDateTime endTime = searchDto.getEndDate().atTime(LocalTime.MAX);
ApiStatisticsSummaryDto summary;
List<ApiStatisticsDetailDto> details;
if (searchDto.hasSelectedApp()) {
// 특정 앱 통계
String clientId = searchDto.getClientId();
// 해당 앱이 조직 소속인지 검증
if (!isAppBelongsToOrg(clientId, orgId)) {
return ApiStatisticsResultDto.empty();
}
summary = apiStatisticsRepository.findSummaryByOrgAndApp(
orgId, clientId, startTime, endTime);
details = apiStatisticsRepository.findDetailByOrgAndApp(
orgId, clientId, startTime, endTime);
} else {
// 전체 앱 통계
summary = apiStatisticsRepository.findSummaryByOrgAndPeriod(
orgId, startTime, endTime);
details = apiStatisticsRepository.findDetailByOrgAndPeriod(
orgId, startTime, endTime);
}
// null 체크 및 빈 결과 처리
if (summary == null) {
summary = ApiStatisticsSummaryDto.empty();
} else {
summary.calculateRates();
}
if (details == null) {
details = Collections.emptyList();
} else {
details.forEach(ApiStatisticsDetailDto::calculateRates);
}
return new ApiStatisticsResultDto(summary, details);
}
/**
* 앱이 해당 조직 소속인지 검증
*/
private boolean isAppBelongsToOrg(String clientId, String orgId) {
return credentialRepository.findByClientidAndOrgid(clientId, orgId).isPresent();
}
/**
* CSV 다운로드
*/
public void downloadCsv(String orgId, ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
ApiStatisticsResultDto result = getStatistics(orgId, searchDto);
String fileName = String.format("API_Statistics_%s_%s.csv",
searchDto.getStartDate().toString(),
searchDto.getEndDate().toString());
response.setContentType("text/csv; charset=UTF-8");
response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) + "\"");
// BOM for Excel UTF-8 recognition
response.getOutputStream().write(0xEF);
response.getOutputStream().write(0xBB);
response.getOutputStream().write(0xBF);
try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), true)) {
// 조회 조건
writer.println("조회 기간," + searchDto.getStartDate() + " ~ " + searchDto.getEndDate());
String appName = "전체 앱";
if (searchDto.hasSelectedApp()) {
appName = credentialRepository.findById(searchDto.getClientId())
.map(Credential::getClientname)
.orElse("선택된 앱");
}
writer.println("앱 구분," + appName);
writer.println();
// 전체 통계
ApiStatisticsSummaryDto summary = result.getSummary();
writer.println("[전체 통계]");
writer.println("총 호출 건," + summary.getTotalAttempted());
writer.println("성공 건," + summary.getTotalCompleted() + "," + summary.getSuccessRate() + "%");
writer.println("실패 건," + summary.getTotalFailed() + "," + summary.getFailureRate() + "%");
writer.println();
// API별 통계 헤더
writer.println("[API별 통계]");
writer.println("API명,호출 건,성공 건,성공율,실패 건,실패율");
// API별 통계 데이터
for (ApiStatisticsDetailDto detail : result.getDetails()) {
writer.println(String.format("%s,%d,%d,%s%%,%d,%s%%",
escapeCsv(detail.getApiName()),
detail.getAttemptedCount(),
detail.getCompletedCount(),
detail.getSuccessRate(),
detail.getFailedCount(),
detail.getFailureRate()));
}
// 전체 합계 행
writer.println(String.format("전체,%d,%d,%s%%,%d,%s%%",
summary.getTotalAttempted(),
summary.getTotalCompleted(),
summary.getSuccessRate(),
summary.getTotalFailed(),
summary.getFailureRate()));
}
}
/**
* CSV 값 이스케이프 (쉼표, 따옴표 처리)
*/
private String escapeCsv(String value) {
if (value == null) return "";
if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
return value;
}
}
@@ -10,10 +10,15 @@ import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -33,6 +38,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@@ -47,6 +53,7 @@ public class AccountController {
private final OrgRegisterFacade orgRegisterFacade;
private final AgreementsFacade agreementsFacade;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
@PostMapping("/confirm_password")
@@ -122,7 +129,8 @@ public class AccountController {
try {
// 현재 로그인된 사용자의 정보를 가져옴
PortalUserDTO user = userFacade.findById(SecurityUtil.getPortalAuthenticatedUser().getId());
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
PortalUserDTO user = userFacade.findById(currentUser.getId());
// 개별 필드들을 모델에 추가
mav.addObject("loginId", user.getLoginId());
@@ -132,6 +140,20 @@ public class AccountController {
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
mav.addObject("user", user);
// ROLE_USER인 경우 초대 여부 확인
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getEmailAddr(), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
mav.addObject("hasPendingInvitation", true);
mav.addObject("invitationToken", pendingInvitation.get().getToken());
} else {
mav.addObject("hasPendingInvitation", false);
}
}
// IP 목록을 분리하여 모델에 추가 (법인 관리자인 경우만)
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
&& user.getPortalOrg() != null
@@ -143,7 +165,7 @@ public class AccountController {
}
// 사용자의 RoleCode에 따라 다른 페이지로 이동
switch (SecurityUtil.getPortalAuthenticatedUser().getRoleCode()) {
switch (currentUser.getRoleCode()) {
case ROLE_USER:
mav.setViewName("apps/mypage/updatePersonalUser");
break;
@@ -252,18 +274,20 @@ public class AccountController {
@PostMapping("/mypage/org-transfer")
public String processOrgTransfer(
@ResponseBody
public Map<String, Object> processOrgTransfer(
@ModelAttribute PortalOrgRegistrationDTO orgDTO,
@ModelAttribute UserAgreementDTO agreementDTO,
HttpServletRequest request,
HttpServletResponse response,
RedirectAttributes redirectAttributes) {
HttpServletResponse response) {
Map<String, Object> result = new HashMap<>();
try {
var currentUser = SecurityUtil.getPortalAuthenticatedUser();
if (currentUser.getRoleCode() != PortalUserEnums.RoleCode.ROLE_USER) {
redirectAttributes.addFlashAttribute("error", "잘못된 접근입니다.");
return "redirect:/mypage";
result.put("status", "ERROR");
result.put("message", "잘못된 접근입니다.");
return result;
}
ValidationResponse validationResponse = orgRegisterFacade.convertToOrgUser(
@@ -291,16 +315,20 @@ public class AccountController {
}
}
redirectAttributes.addFlashAttribute("success", validationResponse.getMessage());
return "apps/mypage/UserConversionCompleted";
result.put("status", "SUCCESS");
result.put("message", validationResponse.getMessage());
result.put("redirectUrl", "/");
return result;
} else {
redirectAttributes.addFlashAttribute("error", validationResponse.getMessage());
return "redirect:/mypage/org-transfer";
result.put("status", "ERROR");
result.put("message", validationResponse.getMessage());
return result;
}
} catch (Exception e) {
redirectAttributes.addFlashAttribute("error", "법인회원 전환 중 오류가 발생했습니다: " + e.getMessage());
return "redirect:/mypage/org-transfer";
result.put("status", "ERROR");
result.put("message", "법인회원 전환 중 오류가 발생했습니다: " + e.getMessage());
return result;
}
}
@@ -43,8 +43,20 @@ public class UserManRestController {
//5. register new user
@PostMapping("/invite")
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
String email = newUser.getEmailAddr();
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), newUser.getEmailAddr());
// 이메일 형식 검증
if (email == null || email.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!email.matches(emailPattern)) {
return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요.");
}
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
}
@@ -56,4 +68,11 @@ public class UserManRestController {
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
}
// 6. Resend invitation
@PostMapping("/resend-invitation")
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
userManFacade.resendInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "초대가 재발송되었습니다.");
}
}
@@ -71,7 +71,8 @@ public class UserRegisterController {
String[] parts = null;
if (invitationToken != null) {
String decodedToken = new String(Base64.decode(invitationToken));
// 8글자 토큰 사용
String decodedToken = decodeInvitationToken(invitationToken);
session.setAttribute("invitationToken", decodedToken);
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
if (invitation.isPresent() && invitation.get().getStatus() == UserInvitationEnums.InvitationStatus.PENDING) {
@@ -112,6 +113,7 @@ public class UserRegisterController {
@Valid @ModelAttribute("portalUser") PortalUserRegistrationDTO portalUserRegistrationDTO,
BindingResult bindingResult,
HttpSession session,
RedirectAttributes redirectAttributes,
Model model) {
try {
if (bindingResult.hasErrors()) {
@@ -134,10 +136,10 @@ public class UserRegisterController {
setModelForError(session, model, agreement, portalUserRegistrationDTO, response.getMessage());
return MAIN_USER_REGISTER;
}
// 성공 시
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
session.removeAttribute("invitationToken");
model.addAttribute("message", "회원가입이 완료되었습니다.");
return invitationToken != null ? MAIN_EMAIL_COMPLETED : MAIN_REGISTER_RESULT;
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
} catch (Exception e) {
setModelForError(session, model, agreement, portalUserRegistrationDTO,
"처리 중 오류가 발생했습니다: " + e.getMessage());
@@ -145,6 +147,28 @@ public class UserRegisterController {
}
}
/**
* 개인회원 가입 완료 페이지
*/
@GetMapping("/signup/complete")
public String showRegistrationComplete(Model model) {
if (!model.containsAttribute("message")) {
return "redirect:/";
}
return MAIN_REGISTER_RESULT;
}
/**
* 법인회원 가입 완료 페이지
*/
@GetMapping("/signup/complete/corporate")
public String showCorporateRegistrationComplete(Model model) {
if (!model.containsAttribute("message")) {
return "redirect:/";
}
return MAIN_EMAIL_COMPLETED;
}
@GetMapping("/signup/decision")
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
HttpSession session,
@@ -153,7 +177,8 @@ public class UserRegisterController {
return "redirect:/";
}
String decodedToken = new String(Base64.decode(invitationToken));
// 8글자 토큰 사용
String decodedToken = decodeInvitationToken(invitationToken);
session.setAttribute("decisionToken", decodedToken);
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
@@ -183,6 +208,7 @@ public class UserRegisterController {
// 이메일 대신 사용자 이름을 모델에 추가
model.addAttribute("userName", portalUser.get().getUserName());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("invitationCode", decodedToken);
model.addAttribute("isInvited", true);
return "apps/register/userDecisionAccept";
@@ -213,6 +239,7 @@ public class UserRegisterController {
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("invitationCode", invitation.get().getToken());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("registrationType", "corporate");
@@ -228,6 +255,7 @@ public class UserRegisterController {
BindingResult bindingResult,
@RequestParam(name = "invitationToken") String invitationToken,
RedirectAttributes redirectAttributes,
HttpSession session,
Model model) {
agreementValidator.validate(agreement, bindingResult);
@@ -249,6 +277,12 @@ public class UserRegisterController {
} else {
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
session.removeAttribute("pendingInvitation");
session.removeAttribute("pendingInvitationToken");
session.removeAttribute("pendingInvitationOrgName");
session.removeAttribute("decisionToken");
redirectAttributes.addFlashAttribute("success", response.getMessage());
return "redirect:/";
}
@@ -294,4 +328,124 @@ public class UserRegisterController {
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
model.addAttribute("msgType", "sms");
}
/**
* 초대 토큰 반환
* @param invitationToken 원본 토큰 (8글자 토큰)
* @return 토큰
*/
private String decodeInvitationToken(String invitationToken) {
return invitationToken;
}
/**
* 초대 코드 입력 페이지 표시
*/
@GetMapping("/signup/invitation-code")
public String showInvitationCodeInput(HttpSession session, Model model) {
// 브루트포스 방지: 세션에서 시도 횟수 확인
Integer failedAttempts = (Integer) session.getAttribute("invitationCodeAttempts");
Long lockoutUntil = (Long) session.getAttribute("invitationCodeLockoutUntil");
if (lockoutUntil != null && lockoutUntil > System.currentTimeMillis()) {
// 잠금 상태: 남은 시간 계산
long remainingSeconds = (lockoutUntil - System.currentTimeMillis()) / 1000;
long remainingMinutes = remainingSeconds / 60;
long seconds = remainingSeconds % 60;
String lockoutTime = String.format("%d분 %d초", remainingMinutes, seconds);
model.addAttribute("lockoutTime", lockoutTime);
model.addAttribute("attemptsRemaining", 0);
} else {
// 잠금 해제: 시도 횟수 표시
if (failedAttempts == null) {
failedAttempts = 0;
}
int attemptsRemaining = 5 - failedAttempts;
model.addAttribute("attemptsRemaining", attemptsRemaining);
}
return "apps/register/invitationCodeInput";
}
/**
* 초대 코드 입력 처리
*/
@PostMapping("/signup/invitation-code")
public String processInvitationCode(
@RequestParam("invitationCode") String invitationCode,
HttpSession session,
RedirectAttributes redirectAttributes,
Model model) {
// 브루트포스 방지: 잠금 상태 확인
Long lockoutUntil = (Long) session.getAttribute("invitationCodeLockoutUntil");
if (lockoutUntil != null && lockoutUntil > System.currentTimeMillis()) {
long remainingSeconds = (lockoutUntil - System.currentTimeMillis()) / 1000;
long remainingMinutes = remainingSeconds / 60;
long seconds = remainingSeconds % 60;
String lockoutTime = String.format("%d분 %d초", remainingMinutes, seconds);
model.addAttribute("lockoutTime", lockoutTime);
model.addAttribute("error", "잠금 해제 시간을 기다려 주세요.");
return "apps/register/invitationCodeInput";
}
// 초대 코드 정규화 (대문자 변환 및 공백 제거)
String normalizedCode = invitationCode.toUpperCase().trim();
// 초대 코드 검증
Optional<UserInvitation> invitationOpt = userInvitationRepository.findByToken(normalizedCode);
if (!invitationOpt.isPresent() ||
invitationOpt.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
// 실패 처리: 시도 횟수 증가
Integer failedAttempts = (Integer) session.getAttribute("invitationCodeAttempts");
if (failedAttempts == null) {
failedAttempts = 0;
}
failedAttempts++;
session.setAttribute("invitationCodeAttempts", failedAttempts);
if (failedAttempts >= 5) {
// 5회 실패: 15분 잠금
long lockoutTime = System.currentTimeMillis() + (15 * 60 * 1000); // 15분
session.setAttribute("invitationCodeLockoutUntil", lockoutTime);
session.setAttribute("invitationCodeAttempts", 0); // 시도 횟수 초기화
model.addAttribute("lockoutTime", "15분 0초");
model.addAttribute("error", "잘못된 초대 코드를 5회 입력하여 15분간 잠금되었습니다.");
} else {
// 실패 메시지와 남은 시도 횟수 표시
int attemptsRemaining = 5 - failedAttempts;
model.addAttribute("attemptsRemaining", attemptsRemaining);
model.addAttribute("error", "유효하지 않은 초대 코드입니다. 다시 확인해 주세요.");
}
return "apps/register/invitationCodeInput";
}
// 성공: 초대 코드 검증 완료
UserInvitation invitation = invitationOpt.get();
// 만료 확인
if (invitation.getExpiresOn().isBefore(java.time.LocalDateTime.now())) {
model.addAttribute("error", "만료된 초대 코드입니다. 관리자에게 재발송을 요청해 주세요.");
return "apps/register/invitationCodeInput";
}
// 시도 횟수 초기화
session.removeAttribute("invitationCodeAttempts");
session.removeAttribute("invitationCodeLockoutUntil");
// 기존 사용자 여부 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
if (existingUser.isPresent()) {
// 기존 사용자: 초대 수락 페이지로 이동
return "redirect:/signup/decision?invitation=" + normalizedCode;
} else {
// 신규 사용자: 회원가입 페이지로 이동
return "redirect:/signup/portalUser?invitation=" + normalizedCode;
}
}
}
@@ -30,11 +30,9 @@ public class PortalOrgRegistrationDTO extends PortalUserRegistrationDTO {
private String orgName;
//대표자 성명
@NotEmpty(message = "{field.required}")
private String ceoName;
//기관사업장소재지
@NotEmpty(message = "{field.required}")
private String orgAddr;
//기관업태
@@ -44,15 +42,12 @@ public class PortalOrgRegistrationDTO extends PortalUserRegistrationDTO {
private String orgIndustryType;
//서비스명
@NotEmpty(message = "{field.required}")
private String serviceName;
//고객센터연락처
@NotEmpty(message = "{field.required}")
private String scPhoneNumber;
//기관전화번호
@NotEmpty(message = "{field.required}")
@PhoneNumber
private String orgPhoneNumber;
@@ -18,6 +18,8 @@ public class ValidationResponse {
private boolean isPersonalAccount;
private boolean isCorporateAccount;
private String authNumber; // 테스트 환경 인증번호
public ValidationResponse(boolean isValid, String message) {
this.isValid = isValid;
this.message = message;
@@ -41,9 +41,10 @@ public class AuthFacadeImpl implements AuthFacade {
}
try {
authNumberService.sendRequestAuthNumber(recipientKey, msgType);
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType);
response.setValid(true);
response.setMessage("인증번호를 발송하였습니다.");
response.setAuthNumber(generatedAuthNumber); // 테스트 환경에서 인증번호 표시용
} catch (Exception e) {
response.setValid(false);
response.setMessage(e.getMessage());
@@ -76,7 +76,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
// 2025.10.20 - 휴대폰 번호 중복 무시
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(orgDTO.getUserName(), orgDTO.getMobileNumber());
Optional<PortalUser> existingUser = portalUserRepository.findByUserName(orgDTO.getUserName());
Optional<PortalUser> existingUser = portalUserRepository.findByLoginId(orgDTO.getLoginId());
if(existingUser.isPresent()) {
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
@@ -181,6 +181,11 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
PortalOrgRegistrationDTO orgDTO,
FileInfo uploadedFile) {
// 사업자등록번호 중복 체크 (이중 방어)
if (portalOrgService.existsByCompRegNo(orgDTO.getCompRegNo())) {
return new ValidationResponse(false, "이미 등록된 사업자등록번호입니다.");
}
// PortalOrgService를 통한 기관 등록
PortalOrg newOrg = portalOrgService.registerOrgFromDTOWithFile(orgDTO, uploadedFile);
@@ -190,7 +195,8 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
approvalService.createUserApproval(newUser);
sendActivationEmail(newUser);
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendActivationEmail(newUser);
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
}
@@ -218,6 +224,11 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
PortalOrgRegistrationDTO orgDTO,
FileInfo uploadedFile) {
// 사업자등록번호 중복 체크 (이중 방어)
if (portalOrgService.existsByCompRegNo(orgDTO.getCompRegNo())) {
return new ValidationResponse(false, "이미 등록된 사업자등록번호입니다.");
}
// PortalOrgService를 통한 기관 등록
PortalOrg newOrg = portalOrgService.registerOrgFromDTOWithFile(orgDTO, uploadedFile);
@@ -243,12 +254,18 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
String newEmail,
FileInfo uploadedFile) {
// 사업자등록번호 중복 체크 (이중 방어)
if (portalOrgService.existsByCompRegNo(orgDTO.getCompRegNo())) {
return new ValidationResponse(false, "이미 등록된 사업자등록번호입니다.");
}
// PortalOrgService를 통한 기관 등록
PortalOrg newOrg = portalOrgService.registerOrgFromDTOWithFile(orgDTO, uploadedFile);
// 새 사용자 생성
existingUser.setLoginId(newEmail);
existingUser.setEmailAddr(newEmail);
// 새 사용자 생성 (이메일 소문자 변환 적용)
String normalizedNewEmail = newEmail != null ? newEmail.toLowerCase() : null;
existingUser.setLoginId(normalizedNewEmail);
existingUser.setEmailAddr(normalizedNewEmail);
existingUser.setPortalOrg(newOrg);
existingUser.setRoleCode(PortalUserEnums.RoleCode.ROLE_CORP_MANAGER);
existingUser.setUserStatus(PortalUserEnums.UserStatus.READY);
@@ -285,9 +302,19 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
@Override
public ResponseEntity<ValidationResponse> checkBusinessNumber(String compRegNo) {
boolean isValid = validationService.isValidBusinessNumber(compRegNo);
String message = isValid ? "사용 가능한 사업자 등록번호입니다." : "유효하지 않은 사업자 등록번호입니다.";
return ResponseEntity.ok(new ValidationResponse(isValid, message));
// 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, "사용 가능한 사업자 등록번호입니다."));
}
@Override
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.apps.user.mapper.PortalOrgMapper;
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.DurationParser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
@@ -18,23 +19,25 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.user.entity.UserLog;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import java.nio.charset.StandardCharsets;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.sound.midi.Receiver;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@@ -48,6 +51,7 @@ public class UserManFacade {
private final UserInvitationRepository userInvitationRepository;
private final MessageHandlerService messageHandlerService;
private final UserLogRepository userLogRepository;
private final PortalPropertyService portalPropertyService;
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
return portalUserRepository
@@ -91,6 +95,19 @@ public class UserManFacade {
}
public void sendInvitation(PortalUser sender, String emailAddr) {
// 이메일 형식 검증
if (emailAddr == null || emailAddr.trim().isEmpty()) {
throw new IllegalArgumentException("이메일 주소를 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!emailAddr.matches(emailPattern)) {
throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요.");
}
// 중복 초대 확인
checkDuplicateInvitation(sender.getPortalOrg().getId(), emailAddr);
//기존 사용자인지 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);
@@ -104,16 +121,32 @@ public class UserManFacade {
String token = createInvitation(sender, emailAddr);
MessageRecipient recipient = new MessageRecipient();
recipient.setUserId(emailAddr);
HashMap<String, Object> params = new HashMap<>();
params.put("manager", sender.getUserName());
params.put("company", sender.getPortalOrg().getOrgName());
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
MessageRecipient recipient;
HashMap<String, Object> params = new HashMap<>();
params.put("managerName", sender.getUserName());
params.put("corpName", sender.getPortalOrg().getOrgName());
params.put("authNumber", token); // 8-character invitation code
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용
if (existingUser.isPresent()) {
params.put("url", "signup/decision?invitation=" + Base64.encode(token.getBytes(StandardCharsets.UTF_8)));
PortalUser user = existingUser.get();
params.put("loginId", user.getUserName());
params.put("NAME", user.getUserName());
params.put("url", "signup/decision?invitation=" + token);
recipient = MessageRecipient.of(user);
} else {
params.put("url", "signup/portalUser?invitation=" + Base64.encode(token.getBytes(StandardCharsets.UTF_8)));
params.put("loginId", emailAddr);
params.put("NAME", emailAddr);
params.put("url", "signup/portalUser?invitation=" + token);
String emailUsername = emailAddr.contains("@") ? emailAddr.substring(0, emailAddr.indexOf("@")) : emailAddr;
recipient = MessageRecipient.builder()
.userId(emailAddr)
.username(emailUsername)
.build();
}
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
@@ -129,18 +162,38 @@ public class UserManFacade {
newInvitation.setInvitationDate(LocalDateTime.now());
newInvitation.setStatus(InvitationStatus.PENDING);
// Generate a unique token (you might want to use a more secure method)
String token = UUID.randomUUID().toString();
// Generate 8-character token for manual entry in closed network
// Excludes confusing characters: O, 0, I, 1
String token = generateEightCharToken();
newInvitation.setToken(token);
// Set expiration date (e.g., 7 days from now)
newInvitation.setExpiresOn(LocalDateTime.now().plusDays(7));
// Set expiration date from PTL_PROPERTY (default: 7 days)
Duration expiration = getInvitationExpiration();
newInvitation.setExpiresOn(LocalDateTime.now().plus(expiration));
// Save the invitation
userInvitationRepository.save(newInvitation);
return token;
}
/**
* 폐쇄망에서 사람이 직접 입력할 있는 8글자 토큰 생성
* 혼동되기 쉬운 문자(O, 0, I, 1) 제외한 대문자 영문자와 숫자 조합
*/
private String generateEightCharToken() {
// 혼동되기 쉬운 문자 제외: O, 0, I, 1
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
SecureRandom random = new SecureRandom();
StringBuilder token = new StringBuilder(8);
for (int i = 0; i < 8; i++) {
int index = random.nextInt(chars.length());
token.append(chars.charAt(index));
}
return token.toString();
}
public void assignManager(PortalAuthenticatedUser portalAuthenticatedUser, String targetUserId) {
PortalUser targetUser = portalUserRepository.findById(targetUserId)
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
@@ -251,4 +304,78 @@ public class UserManFacade {
})
.collect(Collectors.toList());
}
/**
* 초대 재발송
* 기존 PENDING 또는 EXPIRED 상태의 초대를 취소하고 새로운 초대를 생성합니다.
*/
public void resendInvitation(PortalUser sender, String invitationId) {
UserInvitation existingInvitation = userInvitationRepository.findById(invitationId)
.orElseThrow(() -> new NotFoundException("초대를 찾을 수 없습니다: " + invitationId));
// 권한 확인: 같은 기관의 초대인지 확인
if (!existingInvitation.getOrgId().equals(sender.getPortalOrg().getId())) {
throw new IllegalArgumentException("초대를 재발송할 권한이 없습니다.");
}
// 재발송 가능한 상태인지 확인
if (existingInvitation.getStatus() != InvitationStatus.PENDING
&& existingInvitation.getStatus() != InvitationStatus.EXPIRED) {
throw new IllegalStateException("재발송 가능한 상태가 아닙니다. 현재 상태: " + existingInvitation.getStatus().getDescription());
}
// 기존 초대를 취소 처리
existingInvitation.setStatus(InvitationStatus.CANCELED);
userInvitationRepository.save(existingInvitation);
// 새로운 초대 생성 발송
sendInvitation(sender, existingInvitation.getInvitationEmail());
}
/**
* 중복 초대 확인
* 같은 기관에서 같은 이메일로 이미 PENDING 상태인 초대가 있는지 확인
*/
private void checkDuplicateInvitation(String orgId, String emailAddr) {
// 1. 같은 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
emailAddr, orgId, InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다.");
}
// 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> otherOrgPendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
emailAddr, InvitationStatus.PENDING);
if (otherOrgPendingInvitation.isPresent()
&& !otherOrgPendingInvitation.get().getOrgId().equals(orgId)) {
throw new IllegalArgumentException("해당 이메일은 다른 기관에서 초대 진행 중입니다.");
}
}
/**
* PTL_PROPERTY에서 초대 만료 기간을 가져옵니다.
* 설정이 없으면 기본값 7일을 반환합니다.
*
* @return 초대 만료 기간
*/
private Duration getInvitationExpiration() {
try {
Map<String, String> properties = portalPropertyService.getPortalPropertiesAsMap("invitation");
String expirationStr = properties.get("expiration");
if (expirationStr != null && !expirationStr.trim().isEmpty()) {
return DurationParser.parseOrDefault(expirationStr, Duration.ofDays(7));
}
} catch (Exception e) {
// 설정을 가져오는 실패하면 기본값 사용
}
// 기본값: 7일
return Duration.ofDays(7);
}
}
@@ -132,6 +132,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false,"입력값을 확인해 주세요.");
}
// 이메일 중복 체크
if (portalUserService.existsByLoginId(registrationDTO.getLoginId())) {
return new ValidationResponse(false, "이미 사용 중인 이메일입니다.");
}
// 25.10.01 - 휴대폰 번호 중복이여도 가입 가능
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
//
@@ -5,15 +5,31 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@EMSDataSource
public interface PortalOrgRepository extends BaseRepository<PortalOrg, String> {
Optional<PortalOrg> findByCompRegNo(String compRegNo);
/**
* 사업자등록번호 존재 여부 확인 (중복 데이터가 있어도 안전하게 처리)
*/
boolean existsByCompRegNo(String compRegNo);
long countByOrgStatusAndApprovalStatus(PortalOrgEnums.OrgStatus status, PortalOrgEnums.ApprovalStatus approvalStatus);
Optional<PortalOrg> findById(String orgId);
/**
* 정상 상태이고 승인 완료된 기관의 ID 목록 조회
*/
@Query("SELECT o.id FROM PortalOrg o WHERE o.orgStatus = :status AND o.approvalStatus = :approvalStatus")
List<String> findIdsByOrgStatusAndApprovalStatus(
@Param("status") PortalOrgEnums.OrgStatus status,
@Param("approvalStatus") PortalOrgEnums.ApprovalStatus approvalStatus);
}
@@ -58,4 +58,9 @@ public class PortalOrgService {
org.setOrgStatus(OrgStatus.READY);
org.setApprovalStatus(ApprovalStatus.PENDING);
}
// 사업자등록번호 중복 체크 (Spring Data JPA의 existsBy 쿼리 사용 - 중복 데이터에도 안전)
public boolean existsByCompRegNo(String compRegNo) {
return portalOrgRepository.existsByCompRegNo(compRegNo);
}
}
@@ -18,6 +18,16 @@ import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@@ -28,16 +38,6 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
@Service
@Transactional
@RequiredArgsConstructor
@@ -55,7 +55,9 @@ public class PortalUserAuthService implements UserDetailsService {
@Transactional(noRollbackFor = UsernameNotFoundException.class)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
PortalUser portalUser = findByEmailAddr(username);
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
String normalizedUsername = username != null ? username.toLowerCase() : null;
PortalUser portalUser = findByEmailAddr(normalizedUsername);
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
@@ -71,17 +73,22 @@ public class PortalUserAuthService implements UserDetailsService {
}
}
public PortalUserDTO findUsersByNameAndMobile(String userName, String mobileNumber) {
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
try {
PortalUser user = portalUserRepository.findByUserNameAndMobileNumber(userName, mobileNumber);
if (user == null) {
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다.");
}
List<PortalUser> users = portalUserRepository.findAllByUserNameAndMobileNumber(userName, mobileNumber);
if (users == null || users.isEmpty()) {
throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
}
PortalUserDTO dto = portalUserMapper.toDTO(user);
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(dto.getLoginId()));
dto.setLoginId(null);
return dto;
return users.stream().map(user -> {
PortalUserDTO dto = portalUserMapper.toDTO(user);
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(dto.getLoginId()));
dto.setLoginId(null);
return dto;
}).collect(Collectors.toList());
} catch (UserNotFoundException e) {
throw e;
@@ -94,13 +101,19 @@ public class PortalUserAuthService implements UserDetailsService {
return portalUserRepository.findPortalUserByEmailAddr(emailAddr).orElseThrow(() -> new UserNotFoundException("USER_NOT_FOUND_MESSAGE"));
}
public void initializePassword(String loginId, String userName, String mobileNumber) {
public void resetPassword(String loginId, String userName, String mobileNumber) {
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다.");
}
PortalUser portalUser = portalUserRepository.findByLoginIdAndUserNameAndMobileNumber(loginId, userName, mobileNumber)
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
String tempPassword = EncryptionUtil.generateNewPasswordForKjbank(loginId);
String tempPassword = EncryptionUtil.generateNewPassword();
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) {
portalUser.setAccountLockYn("N");
}
portalUserRepository.save(portalUser);
HashMap<String, Object> params = new HashMap<>();
@@ -46,11 +46,15 @@ public class PortalUserService {
}
public Optional<PortalUser> findByLoginId(String loginId) {
return portalUserRepository.findByLoginId(loginId);
// 이메일 소문자 변환 적용
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : null;
return portalUserRepository.findByLoginId(normalizedLoginId);
}
public PortalUser findByEmailAddr(String emailAddr) {
return portalUserRepository.findPortalUserByEmailAddr(emailAddr)
// 이메일 소문자 변환 적용
String normalizedEmail = emailAddr != null ? emailAddr.toLowerCase() : null;
return portalUserRepository.findPortalUserByEmailAddr(normalizedEmail)
.orElseThrow(() -> new IllegalArgumentException("해당 이메일 주소의 사용자를 찾을 수 없습니다."));
}
@@ -59,7 +63,9 @@ public class PortalUserService {
}
public boolean existsByLoginId(String loginId) {
return portalUserRepository.existsByLoginId(loginId);
// 이메일 소문자 변환 적용
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : null;
return portalUserRepository.existsByLoginId(normalizedLoginId);
}
public boolean checkOrgHasOtherUsers(PortalOrg portalOrg) {
@@ -137,11 +143,13 @@ public class PortalUserService {
}
private void mapDtoToEntity(PortalUser user, PortalUserRegistrationDTO dto) {
user.setLoginId(dto.getLoginId());
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
String normalizedEmail = dto.getLoginId() != null ? dto.getLoginId().toLowerCase() : null;
user.setLoginId(normalizedEmail);
user.setUserName(dto.getUserName());
user.setPasswordHash(passwordEncoder.encode(dto.getPassword()));
user.setMobileNumber(dto.getMobileNumber());
user.setEmailAddr(dto.getLoginId());
user.setEmailAddr(normalizedEmail);
}
/**
@@ -176,7 +184,9 @@ public class PortalUserService {
}
public boolean checkPassword(String loginId, String inputPassword) {
PortalUser user = portalUserRepository.findByLoginId(loginId)
// 이메일 소문자 변환 적용
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : null;
PortalUser user = portalUserRepository.findByLoginId(normalizedLoginId)
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
return passwordEncoder.matches(inputPassword, user.getPasswordHash());
}
@@ -24,17 +24,26 @@ public class UserRegistrationValidationService {
}
private boolean isValidOrgInfo(PortalOrgRegistrationDTO orgDTO) {
return basicValidationService.isValidBusinessNumber(orgDTO.getCompRegNo()) &&
basicValidationService.isValidCorpRegNo(orgDTO.getCorpRegNo()) &&
basicValidationService.isValidPhoneNumber(orgDTO.getOrgPhoneNumber()) &&
basicValidationService.isValidPhoneNumber(orgDTO.getScPhoneNumber());
// 사업자등록번호, 법인등록번호는 필수
if (!basicValidationService.isValidBusinessNumber(orgDTO.getCompRegNo()) ||
!basicValidationService.isValidCorpRegNo(orgDTO.getCorpRegNo())) {
return false;
}
// 회사 전화번호, 고객센터 전화번호는 선택 (입력된 경우에만 형식 검증)
if (basicValidationService.isNotEmpty(orgDTO.getOrgPhoneNumber()) &&
!basicValidationService.isValidPhoneNumber(orgDTO.getOrgPhoneNumber())) {
return false;
}
if (basicValidationService.isNotEmpty(orgDTO.getScPhoneNumber()) &&
!basicValidationService.isValidPhoneNumber(orgDTO.getScPhoneNumber())) {
return false;
}
return true;
}
private boolean isValidRequiredFields(PortalOrgRegistrationDTO orgDTO) {
return basicValidationService.isNotEmpty(orgDTO.getOrgName()) &&
basicValidationService.isNotEmpty(orgDTO.getCeoName()) &&
basicValidationService.isNotEmpty(orgDTO.getOrgAddr()) &&
basicValidationService.isNotEmpty(orgDTO.getServiceName());
// 법인명만 필수, 나머지(대표자 성명, 소재지, 서비스명) 선택
return basicValidationService.isNotEmpty(orgDTO.getOrgName());
}
private boolean isValidIpWhitelistIfExists(PortalOrgRegistrationDTO orgDTO) {
@@ -6,6 +6,8 @@ import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.config.PortalProperties;
@ControllerAdvice
public class GlobalControllerAdvice {
@@ -13,6 +15,12 @@ public class GlobalControllerAdvice {
@Autowired
private PageService pageService;
@Autowired
private PortalPropertyService portalPropertyService;
@Autowired
private PortalProperties portalProperties;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -24,4 +32,30 @@ public class GlobalControllerAdvice {
String currentPath = request.getRequestURI();
return pageService.getPageName(currentPath);
}
@ModelAttribute("designSurveyEnabled")
public boolean isDesignSurveyEnabled() {
String value = portalPropertyService.getOrCreateProperty(
"Portal",
"ui.design-survey",
"false",
"네비게이션 바 디자인 설문 활성화 여부 (true/false)"
);
return "true".equalsIgnoreCase(value);
}
@ModelAttribute("showTestAuthNotice")
public boolean showTestAuthNotice() {
return portalProperties.isTestAuthNoticeEnabled();
}
@ModelAttribute("testAuthNumber")
public String getTestAuthNumber() {
if (portalProperties.isTestAuthNoticeEnabled()) {
String authVirtualCode = portalProperties.getAuthVirtualCode();
// 고정 인증번호가 있으면 반환, 없으면 "random" 표시
return (authVirtualCode != null && !authVirtualCode.isEmpty()) ? authVirtualCode : "random";
}
return null;
}
}
@@ -7,8 +7,11 @@ import java.util.Map;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.file.exception.InvalidFileException;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
@@ -28,9 +31,11 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
*/
@Order(2)
@ControllerAdvice(annotations = Controller.class)
@RequiredArgsConstructor
public class PortalGlobalExceptionHandler {
private final Logger log = LoggerFactory.getLogger(getClass());
private final PortalProperties portalProperties;
@ExceptionHandler(value = NotFoundException.class)
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
@@ -62,7 +67,7 @@ public class PortalGlobalExceptionHandler {
log.error(ex.getMessage());
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("errorMessage", ex.getMessage());
modelAndView.setViewName("common/error/error");
modelAndView.setViewName("error");
return modelAndView;
}
@@ -79,7 +84,7 @@ public class PortalGlobalExceptionHandler {
log.error(ex.getMessage());
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("errorMessage", ex.getMessage());
modelAndView.setViewName("common/error/error");
modelAndView.setViewName("error");
return modelAndView;
}
@@ -100,7 +105,7 @@ public class PortalGlobalExceptionHandler {
log.error("Exception occurred: ", ex);
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("errorMessage", ex.getMessage());
modelAndView.setViewName("common/error/error");
modelAndView.setViewName("error");
return modelAndView;
}
@@ -111,4 +116,15 @@ public class PortalGlobalExceptionHandler {
modelAndView.setViewName("redirect:" + request.getRequestURI());
return modelAndView;
}
@ExceptionHandler(value = MaxUploadSizeExceededException.class)
public ModelAndView handleMaxUploadSizeExceededException(HttpServletRequest request, RedirectAttributes redirectAttributes, MaxUploadSizeExceededException ex) {
log.warn("파일 업로드 용량 초과: {}", ex.getMessage());
String maxSize = portalProperties.getFile().getMaxSize();
String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다.";
redirectAttributes.addFlashAttribute("error", errorMessage);
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("redirect:" + request.getRequestURI());
return modelAndView;
}
}
@@ -2,20 +2,28 @@ package com.eactive.apim.portal.common.exception;
import com.eactive.apim.portal.common.dto.ResponseDTO;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.portaluser.service.AuthNumberException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@Order(1)
@RestControllerAdvice(annotations = RestController.class)
@RequiredArgsConstructor
public class PortalRestExceptionHandler {
private final PortalProperties portalProperties;
@ExceptionHandler(value = IntegrationException.class)
public ResponseEntity<ResponseDTO> handleHttpRequestMethodNotSupportedException(HttpServletRequest request, IntegrationException ex) {
@@ -51,4 +59,13 @@ public class PortalRestExceptionHandler {
ResponseDTO response = new ResponseDTO(500, "500", ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(value = MaxUploadSizeExceededException.class)
public ResponseEntity<ResponseDTO> handleMaxUploadSizeExceededException(HttpServletRequest request, MaxUploadSizeExceededException ex) {
log.warn("파일 업로드 용량 초과 (REST): {}", ex.getMessage());
String maxSize = portalProperties.getFile().getMaxSize();
String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다.";
ResponseDTO response = new ResponseDTO(413, "413", errorMessage);
return new ResponseEntity<>(response, HttpStatus.PAYLOAD_TOO_LARGE);
}
}
@@ -0,0 +1,76 @@
package com.eactive.apim.portal.common.util;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 시간 표현 문자열을 Duration으로 파싱하는 유틸리티
* 지원 형식: 5m (5분), 2h (2시간), 7d (7일)
*/
public class DurationParser {
private static final Pattern DURATION_PATTERN = Pattern.compile("^(\\d+)([mhd])$");
/**
* 시간 표현 문자열을 Duration으로 변환
*
* @param durationStr 시간 문자열 (: "5m", "2h", "7d")
* @return Duration 객체
* @throws IllegalArgumentException 형식이 올바르지 않을 경우
*/
public static Duration parse(String durationStr) {
if (durationStr == null || durationStr.trim().isEmpty()) {
throw new IllegalArgumentException("Duration string cannot be null or empty");
}
String normalized = durationStr.trim().toLowerCase();
Matcher matcher = DURATION_PATTERN.matcher(normalized);
if (!matcher.matches()) {
throw new IllegalArgumentException(
"Invalid duration format: " + durationStr +
". Expected format: <number><unit> (e.g., 5m, 2h, 7d)"
);
}
long value = Long.parseLong(matcher.group(1));
String unit = matcher.group(2);
switch (unit) {
case "m":
return Duration.ofMinutes(value);
case "h":
return Duration.ofHours(value);
case "d":
return Duration.ofDays(value);
default:
throw new IllegalArgumentException("Unsupported time unit: " + unit);
}
}
/**
* 시간 표현 문자열을 (day) 단위로 변환
*
* @param durationStr 시간 문자열 (: "5m", "2h", "7d")
* @return (day) 단위 long
*/
public static long parseToDays(String durationStr) {
return parse(durationStr).toDays();
}
/**
* 기본값을 제공하는 안전한 파싱
*
* @param durationStr 시간 문자열
* @param defaultValue 파싱 실패 기본값
* @return Duration 객체
*/
public static Duration parseOrDefault(String durationStr, Duration defaultValue) {
try {
return parse(durationStr);
} catch (Exception e) {
return defaultValue;
}
}
}
@@ -0,0 +1,99 @@
package com.eactive.apim.portal.common.util;
import javax.servlet.http.HttpServletRequest;
/**
* HTTP 요청 관련 유틸리티 클래스
*/
public class HttpRequestUtil {
private static final String[] IP_HEADER_CANDIDATES = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR",
"X-Real-IP"
};
/**
* Proxy를 통한 접속도 고려하여 실제 클라이언트 IP 주소를 반환
*
* @param request HttpServletRequest 객체
* @return 클라이언트 IP 주소
*/
public static String getClientIpAddress(HttpServletRequest request) {
if (request == null) {
return "unknown";
}
String ip = null;
// 헤더들을 순회하면서 IP 주소 찾기
for (String header : IP_HEADER_CANDIDATES) {
ip = request.getHeader(header);
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
break;
}
}
// 헤더에서 IP를 찾지 못한 경우 기본 메서드 사용
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// X-Forwarded-For 헤더는 여러 IP를 포함할 있음 (쉼표로 구분)
// 번째 IP가 실제 클라이언트 IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
/**
* Proxy를 통한 접속도 고려하여 실제 클라이언트 호스트명을 반환
*
* @param request HttpServletRequest 객체
* @return 클라이언트 호스트명
*/
public static String getClientHost(HttpServletRequest request) {
if (request == null) {
return "unknown";
}
String host = request.getHeader("X-Forwarded-Host");
if (host == null || host.isEmpty() || "unknown".equalsIgnoreCase(host)) {
host = request.getRemoteHost();
}
return host;
}
/**
* 요청이 Proxy를 통해 들어왔는지 확인
*
* @param request HttpServletRequest 객체
* @return Proxy를 통한 접속이면 true, 아니면 false
*/
public static boolean isProxied(HttpServletRequest request) {
if (request == null) {
return false;
}
for (String header : IP_HEADER_CANDIDATES) {
String value = request.getHeader(header);
if (value != null && !value.isEmpty() && !"unknown".equalsIgnoreCase(value)) {
return true;
}
}
return false;
}
}
@@ -49,9 +49,11 @@ public final class SecurityUtil {
public static PortalOrgDTO getUserOrg() {
PortalOrgDTO portalOrgDTO = new PortalOrgDTO();
if (Objects.requireNonNull(getPortalAuthenticatedUser()).getPortalOrg() != null) {
portalOrgDTO.setOrgName(getPortalAuthenticatedUser().getPortalOrg().getOrgName());
portalOrgDTO.setId(getPortalAuthenticatedUser().getPortalOrg().getId());
PortalAuthenticatedUser portalAuthenticatedUser = getPortalAuthenticatedUser();
if (Objects.requireNonNull(portalAuthenticatedUser).getPortalOrg() != null) {
portalOrgDTO.setOrgName(portalAuthenticatedUser.getPortalOrg().getOrgName());
portalOrgDTO.setId(portalAuthenticatedUser.getPortalOrg().getId());
portalOrgDTO.setOrgCode(portalAuthenticatedUser.getPortalOrg().getOrgCode());
}
return portalOrgDTO;
@@ -0,0 +1,43 @@
package com.eactive.apim.portal.common.util;
/**
* JDK 8 호환성을 위한 문자열 반복 유틸리티
*/
public class StringRepeatUtil {
/**
* 문자를 지정된 횟수만큼 반복하여 문자열을 생성
*
* @param ch 반복할 문자
* @param count 반복 횟수
* @return 반복된 문자열
*/
public static String repeat(char ch, int count) {
if (count <= 0) {
return "";
}
char[] chars = new char[count];
for (int i = 0; i < count; i++) {
chars[i] = ch;
}
return new String(chars);
}
/**
* 문자열을 지정된 횟수만큼 반복
*
* @param str 반복할 문자열
* @param count 반복 횟수
* @return 반복된 문자열
*/
public static String repeat(String str, int count) {
if (str == null || count <= 0) {
return "";
}
StringBuilder sb = new StringBuilder(str.length() * count);
for (int i = 0; i < count; i++) {
sb.append(str);
}
return sb.toString();
}
}
@@ -0,0 +1,38 @@
package com.eactive.apim.portal.common.util;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import org.apache.commons.lang3.StringUtils;
/**
* 사용자 유형 판별 유틸리티
* 내부 운영자와 외부 사용자를 구분하는 공통 로직 제공
*/
public final class UserTypeUtil {
private UserTypeUtil() {
throw new IllegalStateException("Utility class");
}
private static final String INTERNAL_EMAIL_DOMAIN = "@kjbank.com";
/**
* 현재 사용자가 내부 운영자인지 확인
* @param user 인증된 사용자 정보
* @return 내부 운영자 여부
*/
public static boolean isInternalUser(PortalAuthenticatedUser user) {
if (user == null || StringUtils.isEmpty(user.getEmailAddr())) {
return false;
}
return user.getEmailAddr().toLowerCase().endsWith(INTERNAL_EMAIL_DOMAIN);
}
/**
* 현재 로그인한 사용자가 내부 운영자인지 확인
* @return 내부 운영자 여부
*/
public static boolean isCurrentUserInternal() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return isInternalUser(user);
}
}
@@ -0,0 +1,47 @@
package com.eactive.apim.portal.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
/**
* WAS(WebLogic, JEUS ) 독립적인 Multipart 파일 업로드 설정.
* <p>
* Spring Boot 내장 Tomcat의 spring.servlet.multipart 설정은 외부 WAS에서 동작하지 않으므로,
* CommonsMultipartResolver를 사용하여 모든 환경에서 일관된 파일 업로드 처리를 보장합니다.
* </p>
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class MultipartConfig {
private final PortalProperties portalProperties;
/**
* Commons FileUpload 기반 MultipartResolver 설정.
* <p>
* Bean 이름을 "filterMultipartResolver" 지정하여
* PortalConfigWebDispatcherServlet의 MultipartFilter와 연동합니다.
* </p>
*
* @return CommonsMultipartResolver 인스턴스
*/
@Bean(name = "filterMultipartResolver")
public CommonsMultipartResolver filterMultipartResolver() {
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
long maxSizeBytes = portalProperties.getFile().getMaxSizeBytes();
resolver.setMaxUploadSize(maxSizeBytes);
resolver.setMaxUploadSizePerFile(maxSizeBytes);
resolver.setMaxInMemorySize((int) Math.min(maxSizeBytes, Integer.MAX_VALUE));
resolver.setDefaultEncoding("UTF-8");
log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({})",
maxSizeBytes, portalProperties.getFile().getMaxSize());
return resolver;
}
}

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