Merge remote-tracking branch 'origin/jenkins_with_weblogic' of C:/KJB_DEV/eapim-bundle/bundles/251125-2/eapim-portal_incremental_2025-10-01.bundle into jenkins_with_weblogic

This commit is contained in:
Rinjae
2025-11-25 15:35:06 +09:00
21 changed files with 2745 additions and 34 deletions
+33
View File
@@ -15,6 +15,39 @@
- JPA/Hibernate 5.6.15 with Envers (감사 추적)
- MapStruct (DTO 매핑), Lombok (보일러플레이트 제거)
## 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 사용)
```
## 빌드 명령어
### 애플리케이션 실행
+295 -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,10 @@ 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)
## AI 코딩 어시스턴트 지침 파일
@@ -127,16 +408,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)
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
@@ -19,6 +19,7 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequiredArgsConstructor
public class IndexController {
@@ -10,6 +10,9 @@ 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;
@@ -47,6 +50,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 +126,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 +137,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 +162,7 @@ public class AccountController {
}
// 사용자의 RoleCode에 따라 다른 페이지로 이동
switch (SecurityUtil.getPortalAuthenticatedUser().getRoleCode()) {
switch (currentUser.getRoleCode()) {
case ROLE_USER:
mav.setViewName("apps/mypage/updatePersonalUser");
break;
@@ -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) {
@@ -153,7 +154,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 +185,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 +216,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");
@@ -294,4 +298,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;
}
}
}
@@ -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,24 @@ 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 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 +50,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 +94,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 +120,24 @@ public class UserManFacade {
String token = createInvitation(sender, emailAddr);
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
MessageRecipient recipient = new MessageRecipient();
recipient.setUserId(emailAddr);
HashMap<String, Object> params = new HashMap<>();
params.put("manager", sender.getUserName());
params.put("company", sender.getPortalOrg().getOrgName());
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);
} 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);
}
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
@@ -129,18 +153,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 +295,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);
}
}
@@ -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;
}
}
}
@@ -1,6 +1,11 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
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.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
@@ -34,6 +39,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final PortalUserLogService userLogService;
private final UserPasswordHistoryRepository passwordHistoryRepository;
private final MessageRequestRepository messageRequestRepository;
private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -74,6 +81,27 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
session.setAttribute("redirectUrl", contextPath + "/new_password");
}
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getLoginId(), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
UserInvitation invitation = pendingInvitation.get();
if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) {
// 세션에 초대 정보 저장 (메인 페이지에서 팝업으로 표시)
session.setAttribute("pendingInvitation", true);
session.setAttribute("pendingInvitationToken", invitation.getToken());
// orgId로 기관명 조회
String orgName = portalOrgRepository.findById(invitation.getOrgId())
.map(PortalOrg::getOrgName)
.orElse("알 수 없는 기관");
session.setAttribute("pendingInvitationOrgName", orgName);
}
}
}
String decisionToken = (String) request.getSession().getAttribute("decisionToken");
if (decisionToken != null) {
response.sendRedirect(contextPath + "/signup/decision_process");
@@ -89,6 +89,12 @@
<li class="icon_join">
<a th:href="@{/signup}">회원가입</a>
</li>
<li>
<span>|</span>
</li>
<li class="icon_join">
<a th:href="@{/signup/invitation-code}">초대를 받으셨나요?</a>
</li>
</ul>
</div>
<div th:if="${param.logout}" class="alert alert-info mt-3">
@@ -299,6 +299,20 @@
customPopups.hideAlert('#customAlert');
window.location.href = redirectUrl;
});
return; // 다른 체크 중단
}
// 초대 대기 확인 (로그인할 때마다 표시)
const pendingInvitation = [[${session.pendingInvitation}]];
if (pendingInvitation) {
const invitationToken = /*[[${session.pendingInvitationToken}]]*/ '';
const orgName = /*[[${session.pendingInvitationOrgName}]]*/ '';
customPopups.showConfirm(`<strong>${orgName}</strong>에서 초대가 도착했습니다.<br>초대를 확인하시겠습니까?`, function (selection) {
if (selection) {
window.location.href = '/signup/decision?invitation=' + invitationToken;
}
});
}
});
</script>
@@ -9,6 +9,21 @@
<h2 class="title">내 정보 관리</h2>
</div>
<!-- 초대 알림 배너 -->
<div th:if="${hasPendingInvitation}" class="invitation_alert_banner" style="background-color: #f0f8ff; border: 1px solid #2196F3; border-radius: 4px; padding: 15px 20px; margin: 20px 0;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<strong style="color: #1976D2;">법인 회원 초대가 대기 중입니다</strong>
<p style="margin: 5px 0 0 0; color: #666;">기관에서 귀하를 법인 회원으로 초대했습니다. 초대를 수락하면 해당 기관의 법인 회원으로 전환됩니다.</p>
</div>
<div>
<a th:href="@{/signup/decision(invitation=${invitationToken})}" class="common_btn_type_1 blue" style="white-space: nowrap;">
<span>초대 확인</span>
</a>
</div>
</div>
</div>
<div class="inner i_cs h_inner8">
<form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}">
<div class="terms_box user_top">
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/kbank_base_layout}">
<body>
<section layout:fragment="contentFragment" class="content">
<div class="content_wrap">
<div class="sub_title2">
<h2 class="title add2">초대 코드 입력</h2>
</div>
<div class="inner i_cs9 h_inner9">
<div class="form_type">
<div class="con_title">
<p>법인 기관으로부터 받으신 8자리 초대 코드를 입력해 주세요.</p>
</div>
<form id="invitationCodeForm" method="post" th:action="@{/signup/invitation-code}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="form_type_box">
<div class="info1 form_top">
<p class="title">
<span>초대 코드</span>
</p>
<div class="info_line info_add">
<div class="info_box1 add add2">
<!-- <input type="text"-->
<!-- id="invitationCode"-->
<!-- name="invitationCode"-->
<!-- class="common_input_type_1 h_inp"-->
<!-- placeholder="8자리 초대 코드를 입력해 주세요"-->
<!-- maxlength="8"-->
<!-- pattern="[A-Z0-9]{8}"-->
<!-- title="8자리 영문 대문자와 숫자로 입력해주세요"-->
<!-- required-->
<!-- style="text-transform: uppercase;">-->
<input type="text"
id="invitationCode"
name="invitationCode"
class="common_input_type_1 h_inp"
placeholder="8자리 초대 코드를 입력해 주세요"
maxlength="8"
required
style="text-transform: uppercase;">
</div>
</div>
</div>
<div class="info_txt" style="margin-top: 25px;">
<p>※ 초대 코드는 이메일로 전달받으실 수 있습니다.</p>
<p>※ 초대 코드는 대소문자를 구분하지 않으며, 8자리 영문자와 숫자로 구성되어 있습니다.</p>
</div>
</div>
<!-- 에러 메시지 표시 영역 -->
<div th:if="${error}" class="error_message" style="color: #d32f2f; margin: 25px 0 15px 0; padding: 10px; background-color: #ffebee; border-radius: 4px;">
<p th:text="${error}"></p>
</div>
<!-- 브루트포스 방지: 시도 횟수 표시 -->
<div th:if="${attemptsRemaining != null and attemptsRemaining <= 3}"
class="warning_message"
style="color: #f57c00; margin: 25px 0 15px 0; padding: 10px; background-color: #fff3e0; border-radius: 4px;">
<p>남은 시도 횟수: <strong th:text="${attemptsRemaining}"></strong></p>
<p>5회 실패 시 15분간 입력이 제한됩니다.</p>
</div>
<!-- 브루트포스 방지: 계정 잠금 메시지 -->
<div th:if="${lockoutTime != null}"
class="error_message"
style="color: #d32f2f; margin: 25px 0 15px 0; padding: 10px; background-color: #ffebee; border-radius: 4px;">
<p>잘못된 초대 코드를 5회 입력하셨습니다.</p>
<p th:text="${lockoutTime} + ' 후에 다시 시도해 주세요.'"></p>
</div>
<div class="btn_login">
<button type="submit" class="common_btn_type_1 h_btn" th:disabled="${lockoutTime != null}" style="width: 100%; border: none; cursor: pointer;">
<span>확인</span>
</button>
</div>
</form>
<div class="login_list pc-only" style="margin-top: 20px;">
<ul>
<li>
<a th:href="@{/login}">로그인 페이지로 돌아가기</a>
</li>
<li>
<span>|</span>
</li>
<li>
<a th:href="@{/signup}">회원가입</a>
</li>
</ul>
</div>
<div class="login_list m-only" style="margin-top: 20px;">
<ul>
<li>
<a th:href="@{/login}">로그인 페이지로 돌아가기</a>
</li>
<li>
<span>|</span>
</li>
<li>
<a th:href="@{/signup}">회원가입</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<th:block layout:fragment="contentScript">
<script>
$(document).ready(function() {
// 초대 코드 입력 필드를 대문자로 자동 변환
$('#invitationCode').on('input', function() {
this.value = this.value.toUpperCase();
});
// 성공 메시지가 있으면 표시
var successMsg = [[${success}]];
if (successMsg) {
customPopups.showAlert(successMsg);
}
});
</script>
</th:block>
</body>
</html>
@@ -18,10 +18,19 @@
<p class="tit_txt">
<span th:text="${userName}"></span>님 안녕하세요.<br><br>
<span th:text="${orgName}"></span>에서 귀하를 Kbank API Portal 법인회원으로 초대하였습니다.<br>
<br> Kbank와 함께 금융서비스를 손쉽게 개발해 보세요.<br><br>
<br> 광주은행과 함께 금융서비스를 손쉽게 개발해 보세요.<br><br>
로그인 하여 법인회원 전환을 진행하세요.<br>
</p>
</div>
<div class="info_box" style="margin-top: 20px; padding: 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px;">
<p style="margin: 0; font-size: 14px; color: #666;">
<strong>초대코드:</strong>
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 16px; font-weight: bold; color: #333; letter-spacing: 2px;"></span>
</p>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #999;">
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
</p>
</div>
<div class="btn_application m_top">
<button type="button" onclick="submitForm('reject')" class="common_btn_type_1 gray">
<span>취소</span>
@@ -27,6 +27,15 @@
초대를 수락하시면 법인회원으로 전환됩니다.<br>
</p>
</div>
<div class="info_box" style="margin-top: 20px; padding: 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px;">
<p style="margin: 0; font-size: 14px; color: #666;">
<strong>초대코드:</strong>
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 16px; font-weight: bold; color: #333; letter-spacing: 2px;"></span>
</p>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #999;">
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
</p>
</div>
<div class="btn_application m_top">
<button type="button" onclick="submitForm('reject')" class="common_btn_type_1 gray">
<span>거절</span>
@@ -67,9 +67,10 @@
<td th:text="${user.maskedEmailAddr}">이메일 아이디</td>
<td>
</td>
<td></td>
<td>초대 대기 중</td>
<td>
<div class="btn_wrap btn_set">
<a href="#none" class="common_btn_type_4 resend_invitation"><span>재발송</span></a>
<a href="#none" class="common_btn_type_4 cancel_invitation"><span>초대취소</span></a>
</div>
</td>
@@ -103,8 +104,10 @@
<div class="contents">
<p class="con_txt2">추가할 이용자 e-mail 주소를 입력해 주세요.</p>
<div class="inp_box">
<input type="text" class="common_input_type_1 w_input" id="new_user_email"
placeholder="이용자 e-mail 주소">
<input type="email" class="common_input_type_1 w_input" id="new_user_email"
placeholder="이용자 e-mail 주소"
pattern="[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}"
title="올바른 이메일 형식을 입력해주세요 (예: user@example.com)">
</div>
</div>
</div>
@@ -208,6 +211,13 @@
return;
}
// 이메일 형식 검증
const emailPattern = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
if (!emailPattern.test(email)) {
customPopups.showAlert('올바른 이메일 형식을 입력해주세요. (예: user@example.com)');
return;
}
$.ajax({
url: '/users/invite',
type: 'POST',
@@ -292,6 +302,7 @@
const activateButtons = document.querySelectorAll('.activate_user');
const inactivateButtons = document.querySelectorAll('.inactivate_user');
const cancelInvitationButtons = document.querySelectorAll('.cancel_invitation');
const resendInvitationButtons = document.querySelectorAll('.resend_invitation');
activateButtons.forEach(button => {
button.addEventListener('click', handleUserStatusChange);
@@ -305,6 +316,10 @@
button.addEventListener('click', handleCancelInvitation);
});
resendInvitationButtons.forEach(button => {
button.addEventListener('click', handleResendInvitation);
});
function handleUserStatusChange(event) {
const row = event.target.closest('tr');
const userId = row.querySelector('a').getAttribute('href').split('=')[1];
@@ -361,6 +376,38 @@
});
}
function handleResendInvitation(event) {
const row = event.target.closest('tr');
const userId = row.querySelector('td[data-id]').getAttribute('data-id');
const userName = row.querySelector('td:nth-child(3)').textContent;
customPopups.showConfirm(`${userName}에게 초대를 재발송하시겠습니까?`, function (selection) {
if (selection) {
$.ajax({
url: '/users/resend-invitation',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({id: userId})
}).done(function (response) {
customPopups.showAlert(response.msg, function () {
location.reload(); // Reload the page to reflect changes
});
}).fail(function (jqXHR, textStatus, errorThrown) {
let errorMessage;
try {
const errorResponse = JSON.parse(jqXHR.responseText);
errorMessage = errorResponse.msg || '초대 재발송 중 오류가 발생했습니다';
} catch (e) {
errorMessage = '초대 재발송 중 오류가 발생했습니다: ' + errorThrown;
}
customPopups.showAlert(errorMessage);
}).always(function () {
$('#customConfirm').hide();
});
}
});
}
});
</script>
</th:block>