88 Commits

Author SHA1 Message Date
Rinjae 4395757d98 사용자 최근 작성 글 조회 기능 추가:
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- 최근 3건 조회 서비스/페이사드/리포지토리 구현
- HTML 아코디언 UI 및 스타일 추가
2026-07-13 17:27:06 +09:00
Rinjae 778d579ada # Conflicts:
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
#	src/main/resources/static/css/main.css.map
2026-07-13 13:29:54 +09:00
Rinjae 3e403788e5 비운영 환경 Hibernate SQL 로그 비활성화:
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- show_sql, format_sql 설정 false로 수정
2026-07-13 11:50:01 +09:00
Rinjae 6c5ba6765d 조회수 비정상 증가 방지 로직 추가
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- sessionStorage 활용 중복 증가 억제
- CSRF 토큰 기반 조회수 증가 API 호출 처리
2026-07-13 11:37:06 +09:00
Rinjae a97378b84d Q&A 문의 공개범위 기능 추가
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- 공개범위 설정 기능 개발(ALL/ORG/PRIVATE) - 게시물/댓글 정교화
- 요청 및 DB 처리 확장 - UI, DTO, Service 계층 업데이트
- 감사 로그 및 조회수 처리 로직 추가
2026-07-13 11:32:34 +09:00
Rinjae 9c01db9d00 weblogic.xml 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- XML 태그 단순화 및 불필요한 속성 제거
- session-descriptor에 cookie-name 추가
2026-07-10 17:48:34 +09:00
Rinjae 08bd705dec 세션 유지 기능 추가 및 FAQ 관리 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 비운영 환경 + DB 설정 활성 시 세션 유지(타임아웃 무시) 지원
- FAQ 단건 조회 API 추가 (`FaqFacade`, `FaqFacadeImpl`)
- 헤더 세션 타이머 UI 개선 및 유지 옵션 체크박스 추가
2026-07-10 16:36:19 +09:00
Rinjae 99f765507b 세션 유지 기능 추가 및 FAQ 관리 개선:
- 비운영 환경 + DB 설정 활성 시 세션 유지(타임아웃 무시) 지원
- FAQ 단건 조회 API 추가 (`FaqFacade`, `FaqFacadeImpl`)
- 헤더 세션 타이머 UI 개선 및 유지 옵션 체크박스 추가
2026-07-10 16:36:11 +09:00
Rinjae 7d6aabe122 문의 상태값 'REVIEWING' 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 상태 코드/배지 지원 로직(DjbInquiryStatus) 반영
- Inquiry 댓글 작성자명 관리자 구분 로직 수정
2026-07-10 14:36:23 +09:00
Rinjae f08da86eb3 - Jenkinsfile 경로 수정: WL_HOME, WL_DEPLOY_DIR 디렉토리 변경
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- credentialDetail.html: 비밀값 1회 표시 경고 메시지 추가
2026-07-09 15:26:01 +09:00
Rinjae 2a2505d5f7 관리자 게이트웨이 연동 및 보안 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- AdminGatewayClient 클래스 추가: GW 인증 차단/리로드 처리
- Client Secret 1회 노출+삭제 로직(AppServiceFacade, MyAppController) 추가
- credentialDetail.html에 비밀정보 분실 대응 UI 및 JS 로직 구현
2026-07-08 18:34:33 +09:00
Rinjae cad574324e Jenkinsfile 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- skipDefaultCheckout 옵션 추가 (Deploy 노드 Git 접근 불필요)
2026-07-06 21:05:00 +09:00
Rinjae 4b4ef4196e Jenkinsfile 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- eapim-portal.war 해시 생성 방식 단순화 (sha256만 유지)
- archiveArtifacts 대상 및 스탠징 파일 축소
2026-07-06 20:49:10 +09:00
Rinjae 5402a5354a Jenkinsfile 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 빌드 및 배포 환경을 WebLogic으로 변경
- 다단계 파이프라인 구성 정비 및 에이전트 설정 추가
2026-07-06 20:42:14 +09:00
Rinjae c5b23c3873 PortalProperty 부팅 시 중복키 점검 기능 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- (PROPERTY_GROUP_NAME, PROPERTY_NAME) 유일성 검증
- 중복 발견 시 ERROR 로그 출력 및 수동 정리 안내
- JSESSIONID 쿠키 이름 변경 (PORTAL_JSESSIONID → JSESSIONID_PORTAL)
2026-07-06 17:47:48 +09:00
Rinjae 83d267faed 클라이언트 하드닝 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- DevTools 감지 및 화면 삭제, 우클릭 차단 기능 구현
- PortalProperty 기반 설정 관리 서비스 추가
- 클라이언트 가드 스크립트 및 토글 로직 템플릿 연동
2026-07-06 15:32:18 +09:00
Rinjae b02e598d62 수수료 관리 기능 제거:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 관련 Controller, DTO 클래스 삭제
- commissionManage.html 및 스타일 파일 제거
2026-07-03 18:30:34 +09:00
Rinjae 4f9fbefbcc OAuth2 가이드 문서 업데이트:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 인증 헤더 `X-AUTH-TOKEN`으로 변경
- `scope` 필수값 "api"로 수정
- 오류 코드 및 설명 표준화 (`E.GW.AUTH_FAIL`)
2026-07-01 15:58:21 +09:00
Rinjae c403118289 - StartupInfoPrinter 클래스 추가 - 서비스 시작 시 배너 출력
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- MessageCode 채널 필드 @Deprecated 추가 및 기본값 설정
- MessageCode 신규 생성자 추가 - channel 파라미터 제거
2026-07-01 10:37:08 +09:00
Rinjae a91b56ed11 - 법인 탈퇴 시 민감정보 삭제 로직 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSS 수정: 폰트 크기 제거 및 스타일 변경 적용
- 예상완료일 요일 계산 로직 추가
2026-06-30 15:38:40 +09:00
Rinjae 8d232734b1 IP 등록 UI 및 검증 로직 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `.ip-input-row`/`.btn-add-ip` 정렬 및 버튼 크기 수정
- Callback URL 관련 입력 및 검증 로직 주석 처리
- 화이트리스트 필수 입력 항목 검증 로직 추가
2026-06-30 10:54:22 +09:00
Rinjae 06c18e437b 회원가입 제출 필드 개선: 선택 입력 포함 모든 항목 전송 처리 2026-06-30 10:54:08 +09:00
Rinjae 97b9d2161a IP 입력 필드 스타일 개선: 정렬 및 버튼 크기 수정
- `.ip-input-row` 정렬 기준 추가 및 버튼 폭 고정
- `.btn-add-ip` 및 제거 버튼 스타일/반응형 조정
2026-06-30 10:54:00 +09:00
Rinjae 0c3b63a5b1 - UserSessionService 목 객체 추가 및 관련 의존성 주입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 초대 조회 시 휴대폰 번호 형식 통일 (`01012345678` → `010-1234-5678`)
- AccountControllerTest 테스트 코드 수정 및 누락 보완
2026-06-30 09:45:08 +09:00
Rinjae 582a3e0f69 회원가입 필수 항목 검증 강화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 입력/미충족 항목 구체적 수집 및 에러 메시지 개선
- 시나리오별/공통 항목 검증 로직 추가
2026-06-30 09:42:34 +09:00
Rinjae c1ae12e1ca 전화번호 정규화 유틸 추가 및 적용:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PhoneNumberUtil 도입: 한국번호 정규화/숫자 추출 지원
- 유틸 적용: 모든 저장 및 조회에 정규형 강제
- 초대 매칭 오류 수정: 휴대폰 형식 차이 대응
2026-06-29 18:26:15 +09:00
Rinjae 77aea79363 콘솔 로그 설정 추가 및 레벨 제어 기능 구현
- JVM 파라미터 기반 CONSOLE_LOG_ENABLED, LOG_LEVEL 제어 추가
- Stage 프로파일 정리 및 콘솔 로그 필터 설정 적용
2026-06-29 18:24:37 +09:00
Rinjae aa5e769148 JTA 설정 비활성화 및 신규 회원가입 경로 추가
- `spring.jta.enabled` 값 false로 변경 (stage/dev/prod 공통)
- 법인회원 초대 경로(`/signup/decision_process`) 신규 추가
2026-06-29 18:24:26 +09:00
Rinjae ee8e9e3529 CycloneDX BOM 플러그인 추가 - build.gradle에 org.cyclonedx.bom 적용 2026-06-29 18:24:07 +09:00
Rinjae 82948a13ce CSRF 만료 세션 처리 및 정적 리소스 캐싱 제어 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSRF 만료 시 기존 세션 만료 안내로 리다이렉트 처리
- 정적 리소스 캐싱 토글 기능 추가 (개발 환경 즉시 반영 지원)
- 회원가입 페이지 및 세션 설정 스타일 수정
2026-06-26 15:05:19 +09:00
Rinjae f79e528fec 이메일 인증 재발송 기능 제거 및 관련 코드 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `emailResendPopup` 및 팝업 호출 로직 삭제
- `InquiryAdminNotifier` → `CommunityAdminNotifier`로 클래스 명칭 변경
- 제휴 신청 시 관리자 알림 로직 추가
2026-06-24 19:45:37 +09:00
Rinjae 6f776ed72a 로그아웃 핸들러 추가: 세션 무효화 전 DB 정리 및 로깅 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-24 16:52:09 +09:00
Rinjae e242d3ed06 - 모바일 약관 탭 UI 개선 - 스크롤 추가 및 스타일 조정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 필수 입력 경고 메세지 추가 및 인증 미완료 경고 추가
2026-06-24 15:32:18 +09:00
Rinjae d7abe2c2e5 - 테스트 데이터에 휴대폰 번호 추가 및 관련 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 초대 조회 조건: 이메일 → 휴대폰 번호로 변경
2026-06-23 21:06:28 +09:00
Rinjae 84d92fced9 - 초대 로직 변경: 이메일→휴대폰 기반 초대 전환 및 UI 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 알림 수신 동의 추가: 초대/취소 시 알림 발송 옵션 탑재
- 관리자 기능 추가: 소속 제외 및 권한 조정 기능 구현
2026-06-23 20:58:27 +09:00
Rinjae 53468c3b83 WAR 파일 해시 파일 확장자 변경 및 MD5SUM 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-23 13:58:02 +09:00
Rinjae 198f6fc82a - 회원가입 이메일 인증 페이지 추가 및 이메일 인증 흐름 구현
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 휴대폰 번호 중복 검증 로직 추가 및 프로퍼티 기반 설정 활성화
- 알림 수신 동의 항목 및 뷰 로직 추가
2026-06-22 19:20:03 +09:00
Rinjae 21c500610c Damo 적용 준비 코드
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- KjbNotRnnoJpaAttributeConverter 삭제 및 PersonalDataEncryptConverter 추가
- damo-manager 지원을 위한 JSP 추가 및 레거시 암호화 마이그레이션 컨트롤러 구현
- 기존 엔티티 암호화 로직 변경 및 build.gradle에 damo-manager 의존성 추가
2026-06-22 16:29:45 +09:00
Rinjae 687d4798d4 - error 페이지 개발 환경용 상세 오류 정보 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 스택 트레이스 및 예외 클래스 노출 로직 구현
- activeProfile 표시 및 스타일 업데이트
2026-06-22 15:39:59 +09:00
Rinjae 5adfd42751 - CSRF 토큰 저장소 쿠키 → 세션 전환 및 클라이언트 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- error 페이지 텍스트 동적 처리 추가 (prod 환경 구분)
- CSRF 메타 데이터 추가 및 JS/AJAX CSRF 로직 일원화
2026-06-22 14:19:32 +09:00
Rinjae 6694715195 Merge branch 'feature/session-timeout'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-06-19 18:09:47 +09:00
Rinjae fc96c13df2 - 로그인 중복 세션 확인 및 기존 세션 강제 로그아웃 처리 추가
- 세션 타이머 데스크톱·모바일 표시 및 경고 스타일 동기화
2026-06-19 18:06:40 +09:00
Rinjae 7265ad6589 - 세션 타이머·유휴 자동 로그아웃 기능 추가
- 만료 직전 연장 확인 모달, PortalProperty로 시간 설정
- PTL_USER_SESSION 기반 세션 검증 필터/API 도입
- 로그인 시 중복 세션 강제 로그아웃 처리
- 세션 테이블 DDL 및 로그인 안내 메시지 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:45:37 +09:00
Rinjae 02f9d41334 OAuth2 개발가이드 추가 및 테이블 UI 개선
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OAuth2 개발가이드 링크 추가
- 테이블 컬럼 스타일 조정: 모바일 대응 및 오버플로 방지
- Jackson 직렬화 대응 날짜 포맷 수정
2026-06-19 17:22:33 +09:00
Rinjae fe4b176512 CSS source map 경로 수정 및 매핑 데이터 규칙 정비
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-19 10:27:17 +09:00
Rinjae 17c27e4398 문의 상태값 수정: IN_PROGRESS → RESPONDED
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 문의 상태 필드 및 관련 로직/뷰 업데이트
- Validation 패턴 및 테스트 코드 반영
- 뷰 상태 배지 및 응답 섹션 표시 로직 수정
2026-06-19 10:16:16 +09:00
Rinjae 10ff5d6f2f 마스킹 표준화 적용 (eapim-admin/portal/JS)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- admin: elink-online-core 표준 위임(e메일/이름/전화/IP)으로 규칙 일원화
- portal/JS: 표준 로직 직접 구현 + 기존 owner 비마스킹 유지
- 호출처 무수정/인라인 중복 제거/테스트 통과
2026-06-18 17:56:32 +09:00
Rinjae-gf63 f671f491b7 - PortalUser-테이블명 대소문자 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Credential-EqualsAndHashCode 어노테이션 추가
- 사업제휴신청 메시지 수정 및 파일 null 검사 추가
2026-06-18 16:28:22 +09:00
Rinjae-gf63 3d68589ae7 - 불필요 로그 설정 및 springProfile(gf63) 제거
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- "사업 제휴 문의" 텍스트를 "피드백/개선요청"으로 변경
- 과금 관리 관련 링크 제거
2026-06-18 16:03:03 +09:00
rinjae 7268aef211 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 13:51:14 +09:00
rinjae 85b2f01ce0 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 13:43:24 +09:00
rinjae 0f255d7337 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 12:17:31 +09:00
rinjae a8323e78f5 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 12:11:00 +09:00
Rinjae fa8fce4f2d Jenkinsfile 추가 및 CI/CD 스크립트 통합
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 기존 *.sh 스크립트 제거 및 Jenkinsfile.deploy로 통합
- 테스트 전용 Jenkinsfile.test-build 추가
2026-06-18 11:35:12 +09:00
Rinjae 4e7044b9fe 폰트 패밀리 정의 분리 및 웹폰트 리소스 경로 정비
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OneShinhan, Spoqa Han Sans Neo 등 역할별 폰트 패밀리 분리
- 제목/본문 영역에 맞는 폰트 설정 및 재정의
- 웹폰트 리소스 URL 경로 및 성능 최적화
2026-06-17 20:47:01 +09:00
Rinjae bddd7e3f85 공지사항/Q&A 개선 - 장애 점검 정보 노출 및 상단고정 정렬 - Q&A 이벤트 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-16 14:54:02 +09:00
Rinjae 71f4cb6f79 Q&A 이벤트 리팩토링
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- InquiryCommentCreatedAdminEvent 삭제 및 경로 변경
- userName 정보 추가 및 description 업데이트
- inquirySubject 처리 로직 개선
2026-06-15 18:57:51 +09:00
Rinjae 171feee9ea Q&A 댓글 관련 코드 리팩토링 및 역할 기반 알림 기능 개선
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- InquiryComment 코드 구조 리팩토링 및 역할 분리
- portal-admin 알림 기능 추가 및 예외 처리 개선
- InquiryComment 관련 불필요 클래스/메서드 제거
2026-06-15 18:49:14 +09:00
Rinjae 3f664bcd5c Q&A 댓글 기능 추가 및 첨부파일 제거
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- djb 패키지 분리 - 댓글 CRUD/권한/알림 일체
- 같은 법인 공유 - 상세/댓글 조회 정책 일원화
- 작성자명 매핑 - PortalUser/TSEAIRM02 양쪽 조회
- 목록 - 댓글 수 표시 및 첨부 UI/로직 제거

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

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

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

Before

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+84 -466
View File
@@ -1,33 +1,30 @@
{
"name": "eapim-portal",
"version": "1.0.0",
"lockfileVersion": 2,
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "eapim-portal",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright": "^1.57.0"
},
"devDependencies": {
"sass": "^1.69.5"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
@@ -37,25 +34,25 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
@@ -74,9 +71,9 @@
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
@@ -95,9 +92,9 @@
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
@@ -116,9 +113,9 @@
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
@@ -137,9 +134,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
@@ -158,9 +155,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
@@ -179,9 +176,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
@@ -200,9 +197,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
@@ -221,9 +218,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
@@ -242,9 +239,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
@@ -263,9 +260,9 @@
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
@@ -284,9 +281,9 @@
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
@@ -305,9 +302,9 @@
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
@@ -325,82 +322,37 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 14.16.0"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true,
"license": "MIT"
},
@@ -429,32 +381,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -464,57 +390,27 @@
"optional": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8.6"
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
@@ -522,21 +418,21 @@
}
},
"node_modules/sass": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.1.tgz",
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz",
"integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
@@ -551,284 +447,6 @@
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
},
"dependencies": {
"@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"optional": true,
"requires": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
}
},
"@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"dev": true,
"optional": true
},
"@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"dev": true,
"optional": true
},
"@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"dev": true,
"optional": true
},
"@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"dev": true,
"optional": true
},
"@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"dev": true,
"optional": true
},
"@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"dev": true,
"optional": true
},
"@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"dev": true,
"optional": true
},
"braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"optional": true,
"requires": {
"fill-range": "^7.1.1"
}
},
"chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"requires": {
"readdirp": "^4.0.1"
}
},
"detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"optional": true
},
"fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"optional": true,
"requires": {
"to-regex-range": "^5.0.1"
}
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
"immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"dev": true
},
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"optional": true
},
"is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"optional": true,
"requires": {
"is-extglob": "^2.1.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"optional": true
},
"micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"optional": true,
"requires": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
}
},
"node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"optional": true
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"optional": true
},
"playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"requires": {
"fsevents": "2.3.2",
"playwright-core": "1.57.0"
}
},
"playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="
},
"readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true
},
"sass": {
"version": "1.97.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.1.tgz",
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"dev": true,
"requires": {
"@parcel/watcher": "^2.4.1",
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
}
},
"source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"optional": true,
"requires": {
"is-number": "^7.0.0"
}
}
}
}
+1 -4
View File
@@ -13,8 +13,5 @@
"sass": "^1.69.5"
},
"author": "",
"license": "ISC",
"dependencies": {
"playwright": "^1.57.0"
}
"license": "ISC"
}
@@ -0,0 +1,36 @@
-- =============================================================================
-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
-- 엔티티: com.eactive.apim.portal.apps.session.entity.UserSession
-- =============================================================================
CREATE TABLE PTL_USER_SESSION
(
SESSION_ID VARCHAR2(128) NOT NULL,
USER_ID VARCHAR2(36) NOT NULL,
LOGIN_ID VARCHAR2(200) NOT NULL,
LOGIN_TIME TIMESTAMP(6) NOT NULL,
LAST_ACCESS_TIME TIMESTAMP(6) NOT NULL,
IP_ADDRESS VARCHAR2(45),
USER_AGENT VARCHAR2(500),
FORCE_LOGOUT CHAR(1) DEFAULT 'N' NOT NULL,
CONSTRAINT PK_PTL_USER_SESSION PRIMARY KEY (SESSION_ID)
);
-- 중복로그인 강제 로그아웃 / 활성 세션 조회는 LOGIN_ID 기준
CREATE INDEX IX_PTL_USER_SESSION_LOGIN ON PTL_USER_SESSION (LOGIN_ID);
COMMENT ON TABLE PTL_USER_SESSION IS '포털 사용자 세션 추적 (타이머/유휴 로그아웃/중복로그인)';
COMMENT ON COLUMN PTL_USER_SESSION.SESSION_ID IS 'HTTP 세션 ID (PK)';
COMMENT ON COLUMN PTL_USER_SESSION.USER_ID IS '포털 사용자 ID';
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_ID IS '로그인 ID (이메일, 소문자)';
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_TIME IS '로그인 시각';
COMMENT ON COLUMN PTL_USER_SESSION.LAST_ACCESS_TIME IS '마지막 접근 시각 (만료 판단 기준)';
COMMENT ON COLUMN PTL_USER_SESSION.IP_ADDRESS IS '접속 IP';
COMMENT ON COLUMN PTL_USER_SESSION.USER_AGENT IS 'User-Agent';
COMMENT ON COLUMN PTL_USER_SESSION.FORCE_LOGOUT IS '강제 로그아웃 플래그 (Y/N)';
-- (선택) 세션 타임아웃(분) 설정값을 미리 지정. 미삽입 시 최초 접근 때 기본값 15로 자동 생성됨.
-- INSERT INTO PTL_PROPERTY (PROPERTY_GROUP_NAME, PROPERTY_NAME, PROPERTY_VALUE, PROPERTY_DESC)
-- VALUES ('Portal', 'session.timeout.minutes', '15', '세션 타임아웃 시간 (분)');
-- COMMIT;
@@ -0,0 +1,23 @@
-- =============================================================================
-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
--
-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
--
-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
-- =============================================================================
ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
COMMIT;
+1 -5
View File
@@ -4,8 +4,4 @@ include 'elink-online-core-jpa'
include 'elink-portal-common'
project (':elink-online-core-jpa').projectDir = new File(settingsDir, "../eapim-online/elink-online-core-jpa")
project (':elink-portal-common').projectDir = new File(settingsDir, "../elink-portal-common")
//include 'kjb-safedb'
//project (':kjb-safedb').projectDir = new File(settingsDir, "../kjb-safedb")
project (':elink-portal-common').projectDir = new File(settingsDir, "../elink-portal-common")
@@ -0,0 +1,67 @@
package com.eactive.apim.portal.apps;
import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Readiness probe.
*
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
* This endpoint validates both EMS and Gateway datasources via JDBC
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
* requests that depend on the database.
*
* <p>HTTP 200 + JSON when all checks pass.
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
* status so CI/CD logs the failing dependency.
*/
@RestController
public class ReadinessController {
private static final int VALIDATION_TIMEOUT_SECONDS = 2;
private final DataSource portalDataSource;
private final DataSource gatewayDataSource;
public ReadinessController(
@Qualifier("portalDataSource") DataSource portalDataSource,
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
this.portalDataSource = portalDataSource;
this.gatewayDataSource = gatewayDataSource;
}
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> ready() {
String ems = check(portalDataSource);
String gw = check(gatewayDataSource);
boolean ok = "UP".equals(ems) && "UP".equals(gw);
String body = "{"
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
+ "\"ems\":\"" + ems + "\","
+ "\"gateway\":\""+ gw + "\""
+ "}";
return ResponseEntity.status(ok ? 200 : 503)
.contentType(MediaType.APPLICATION_JSON)
.body(body);
}
private static String check(DataSource ds) {
if (ds == null) return "DOWN:NO_DATASOURCE";
try (Connection c = ds.getConnection()) {
return c.isValid(VALIDATION_TIMEOUT_SECONDS) ? "UP" : "DOWN:INVALID";
} catch (SQLException e) {
String msg = e.getMessage() == null ? "" : e.getMessage();
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
}
}
}
@@ -15,7 +15,11 @@ import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequestMapping("/agreements")
public class AgreementsController {
public static final String TERMS_AGREEMENTS = "fragment/kjbank/terms_agreements";
public static final String TERMS_AGREEMENTS = "fragment/djbank/terms_agreements";
/** 개인정보처리방침은 약관 페이지에서 분리되어 제주은행 공식 사이트 외부 링크로 대체됨 */
public static final String PRIVACY_POLICY_EXTERNAL_URL =
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
private final AgreementsFacade agreementsFacade;
@@ -28,28 +32,49 @@ public class AgreementsController {
public String showTerms(@RequestParam(required = false) String tab,
@RequestParam(required = false) String publishedOn,
Model model) {
String currentTab = tab != null ? tab : "terms";
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
if ("privacy".equals(currentTab)) {
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
}
AgreementType type;
if ("privacy".equals(tab)) {
type = AgreementType.PRIVACY_POLICY;
} else {
type = AgreementType.TERMS_OF_USE;
switch (currentTab) {
case "privacy-collect":
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
type = AgreementType.PRIVACY_COLLECT;
break;
case "notification":
type = AgreementType.NOTIFICATION_CONSENT;
break;
case "terms":
default:
currentTab = "terms";
type = AgreementType.TERMS_OF_USE;
break;
}
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
model.addAttribute("agreementsList", agreementsList);
AgreementsDTO selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
// 해당 약관 종류가 아직 시드되지 않았을 수 있으므로 빈 리스트 방어
AgreementsDTO selectedAgreement = null;
if (agreementsList != null && !agreementsList.isEmpty()) {
selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
}
model.addAttribute("selectedAgreement", selectedAgreement);
model.addAttribute("selectedDate", publishedOn);
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
model.addAttribute("isPrivacyPolicy", type == AgreementType.PRIVACY_POLICY);
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
model.addAttribute("agreementTitle", type.getDescription());
model.addAttribute("agreementType", type.getCode());
model.addAttribute("currentTab", tab != null ? tab : "terms");
model.addAttribute("currentTab", currentTab);
return TERMS_AGREEMENTS;
}
@@ -71,8 +71,9 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
}
// 2. 개인정보수집동의서 처리
if (privacyCollectType != null && privacyCollectType.isPrivacyCollect()) {
// 2. 개인정보수집동의서 처리 (개인정보수집동의서 = PRIVACY_COLLECT 신설항목 포함)
if (privacyCollectType != null
&& (privacyCollectType.isPrivacyCollect() || privacyCollectType == AgreementType.PRIVACY_COLLECT)) {
Optional<Agreements> privacyCollect = agreementsService
.findLastesBeforeDate(
privacyCollectType,
@@ -199,6 +200,10 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
defaultAgreement.setName("개인정보처리방침");
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
break;
case "PRIVACY_COLLECT":
defaultAgreement.setName("개인정보수집동의서");
defaultAgreement.setContents("현재 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
break;
case "PRIVACY_COLLECT_IND":
defaultAgreement.setName("개인용 개인정보수집동의서");
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
@@ -2,15 +2,15 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.FileCopyUtils;
@@ -22,35 +22,45 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/apis")
@RequiredArgsConstructor
@Slf4j
public class TestbedSpecController {
@Autowired
private ApiSpecInfoService apiSpecInfoService;
private final ApiSpecInfoService apiSpecInfoService;
private final DjbTestbedSpecServerRewriter serverRewriter;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> getSwagger(@PathVariable String id) {
public ResponseEntity<String> getSwagger(@PathVariable String id, HttpServletRequest request) {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> getSwaggerYaml(@PathVariable String id, HttpServletRequest request) {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
try {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
return ResponseEntity.ok().body(content);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
} catch (IOException e) {
log.error("Failed to read default token api spec file", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
return null;
}
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
if (!spec.isPresent()) {
StringUtils.hasText(spec.get().getTestbedSpec());
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null;
}
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.IOException;
@@ -20,6 +21,20 @@ public class APISender {
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
private final DjbTestbedGatewayProperty gatewayProperty;
public APISender(DjbTestbedGatewayProperty gatewayProperty) {
this.gatewayProperty = gatewayProperty;
}
/** connect/read 타임아웃 적용. 반드시 connect(getOutputStream/getResponseCode) 이전에 호출. */
private void applyTimeout(HttpURLConnection connection) {
int t = gatewayProperty.timeoutMillis();
connection.setConnectTimeout(t);
connection.setReadTimeout(t);
}
public String requestPost(String uri, String requestBody) throws IOException {
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
@@ -58,6 +73,7 @@ public class APISender {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "application/json");
for (Map.Entry<String, String> entry : headers.entrySet()) {
@@ -82,6 +98,7 @@ public class APISender {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("POST");
for (Map.Entry<String, String> entry : headers.entrySet()) {
@@ -125,10 +142,11 @@ public class APISender {
return response.toString();
}
private static HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
URL endpoint = new URL(uri);
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
@@ -4,6 +4,8 @@ package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.io.BufferedReader;
import java.io.IOException;
import java.net.URI;
@@ -20,6 +22,7 @@ import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -56,20 +59,42 @@ public class ApiTesterFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
if (httpServletRequest.getRequestURI().contains("/api/call-api")) {
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url");
// "/api/call-api" 경로가 아닌 경우 필터링하지 않고 다음 필터로 넘어감
if (!httpServletRequest.getRequestURI().contains("/api/call-api")) {
chain.doFilter(request, response);
return;
}
if (url.contains("/api/v1/oauth/token")){
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url");
// Parse request parameters
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) {
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
return;
}
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
// 반환하기 위해 try 로 감싼다.
try {
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath());
if (tokenRequest) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
for (String pair : pairs) {
@@ -81,7 +106,7 @@ public class ApiTesterFilter implements Filter {
String scope = params.getOrDefault("scope", "default");
String token = "{\n" +
" \"access_token\": \"kjbank_gw_sample_token\",\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" +
" \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" +
@@ -90,64 +115,152 @@ public class ApiTesterFilter implements Filter {
response.setContentType("application/json");
response.getWriter().println(token);
} else {
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/x-www-form-urlencoded");
headers.put("Accept", "application/json");
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
} else {
String mockUrl = apiSpecInfoDto.getMockUrl();
String method = apiSpecInfoDto.getApiMethod();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
response.setContentType("application/json");
response.getWriter().println(tokenResponse);
}
Map<String, String> headers = new HashMap<>();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
}
Map<String, String[]> paramMap = new HashMap<>();
} else {
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
String originalUrl = headers.get("original-url");
//sprlit original url with ? get the second part then split with & put into paramMap
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) {
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
return;
}
String[] originalUrlArr = originalUrl.split("\\?");
if (originalUrlArr.length > 1) {
String[] paramArr = originalUrlArr[1].split("&");
for (String param : paramArr) {
String[] paramKeyValue = param.split("=");
if (paramKeyValue.length > 1) {
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
}
}
}
String responseType = apiSpecInfoDto.getResponseType();
headers.remove("original-url");
headers.remove("original-api-id");
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
return;
}
String responseStr = "";
if (method.equalsIgnoreCase("post")) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
// - mock : ApiSpecInfo.mockUrl (기존 동작)
boolean gw = "gw".equalsIgnoreCase(responseType);
} else {
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
}
headers.remove("original-url");
headers.remove("original-api-id");
String targetUri;
Map<String, String[]> paramMap;
if (gw) {
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
headers.remove("host");
headers.remove("Host");
targetUri = url;
paramMap = new HashMap<>();
} else {
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
targetUri = apiSpecInfoDto.getMockUrl();
paramMap = extractQueryParams(url);
}
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
} catch (java.net.SocketTimeoutException e) {
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (IOException e) {
// 연결 실패 등 네트워크 오류
logger.error("테스트베드 프록시 호출 실패", e);
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (Exception e) {
// 그 외 예기치 못한 오류도 JSON 으로 반환
logger.error("테스트베드 프록시 처리 오류", e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
}
}
/** 요청 본문 전체를 문자열로 읽는다. */
private String readBody(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
private Map<String, String[]> extractQueryParams(String originalUrl) {
Map<String, String[]> paramMap = new HashMap<>();
String[] parts = originalUrl.split("\\?");
if (parts.length > 1) {
for (String param : parts[1].split("&")) {
String[] kv = param.split("=");
if (kv.length > 1) {
paramMap.put(kv[0], new String[]{kv[1]});
}
}
} else {
chain.doFilter(request, response);
}
return paramMap;
}
/** 상태코드 + JSON 본문 응답. */
private void writeJson(ServletResponse response, int status, String json) throws IOException {
((HttpServletResponse) response).setStatus(status);
response.setContentType("application/json");
response.getWriter().println(json);
}
/** JSON 문자열 값에 넣기 위한 최소 escape (예외 메시지 등). */
private String escapeJson(String s) {
if (s == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '\\': sb.append("\\\\"); break;
case '"': sb.append("\\\""); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
return sb.toString();
}
@Override
@@ -10,6 +10,7 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper;
@@ -21,6 +22,7 @@ import java.util.Map;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -44,6 +46,7 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Slf4j
@Controller
@RequestMapping("/myapikey")
@RequiredArgsConstructor
@@ -77,6 +80,7 @@ public class MyAppController {
private final ApiService apiService;
private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient;
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
@@ -168,7 +172,13 @@ public class MyAppController {
}
});
// Client Secret은 초기 HTML에 담지 않는다. (비번 확인 후 서버가 1회만 반환)
boolean secretAvailable = StringUtils.isNotBlank(apiKey.getClientsecret());
apiKey.setClientsecret(null);
model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("authType", "OAuth2");
return new ModelAndView(CREDENTIAL_DETAIL);
}
@@ -228,28 +238,45 @@ public class MyAppController {
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
Map<String, Object> result = new java.util.HashMap<>();
try {
String clientId = requestData.get("clientId");
String type = requestData.get("type");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
result.put("msg", "클라이언트 ID가 필요합니다.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
// API Key 즉시 삭제 (승인 프로세스 없이)
appServiceFacade.deleteApp(user.getPortalOrg().getId(), clientId);
result.put("success", true);
result.put("msg", "API Key가 삭제되었습니다.");
} catch (Exception e) {
String clientId = requestData.get("clientId");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
result.put("msg", "클라이언트 ID가 필요합니다.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String orgId = user.getPortalOrg().getId();
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
result.put("success", false);
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
return result;
}
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
try {
adminGatewayClient.blockClient(clientId);
} catch (Exception e) {
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
result.put("success", false);
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
return result;
}
// 3. GW 차단 성공 시에만 포털 credential 삭제
try {
appServiceFacade.deleteApp(orgId, clientId);
} catch (Exception e) {
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
result.put("success", false);
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
return result;
}
result.put("success", true);
result.put("msg", "API Key가 삭제되었습니다.");
return result;
}
@@ -282,6 +309,57 @@ public class MyAppController {
return result;
}
/**
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
*
* @param requestData clientId, password 포함
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
*/
@PostMapping("/credential/reveal-secret")
@Secured("ROLE_APP")
@ResponseBody
public Map<String, Object> revealClientSecret(@RequestBody Map<String, String> requestData) {
Map<String, Object> result = new java.util.HashMap<>();
String clientId = requestData.get("clientId");
String password = requestData.get("password");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
result.put("message", "클라이언트 ID가 필요합니다.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
// 1. 본인 확인 (비밀번호)
if (!appServiceFacade.verifyUserPassword(user, password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
try {
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
if (secret == null) {
result.put("success", false);
result.put("alreadyRevealed", true);
result.put("message", "이미 1회 노출되어 삭제된 인증정보입니다.");
return result;
}
result.put("success", true);
result.put("secret", secret);
} catch (Exception e) {
log.error("Client Secret 노출 처리 실패 - clientId={}", clientId, e);
result.put("success", false);
result.put("message", "인증정보 조회 중 오류가 발생했습니다.");
}
return result;
}
/**
* API Key 요청 이력을 페이지 형태로 조회합니다.
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.app.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
/**
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
*
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AdminGatewayClient {
private static final String PROP_GROUP = "Portal";
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
private final RestTemplate restTemplate;
private final PortalPropertyService portalPropertyService;
/**
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
*
* @param clientId 차단할 클라이언트 ID
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
*/
public void blockClient(String clientId) {
String baseUrl = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
Map<?, ?> body = response.getBody();
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
if (!success) {
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
}
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
}
}
@@ -293,6 +293,29 @@ public class AppServiceFacade {
}
}
/**
* Client Secret을 1회 조회하고 그 즉시 DB(PTL_CREDENTIAL)에서 물리 삭제합니다.
* 보안 정책상 비밀정보는 최초 1회만 제공되며, 조회 후에는 복구할 수 없습니다.
*
* @param orgId 조직 ID (소유권 확인용)
* @param clientId 대상 클라이언트 ID
* @return 삭제 전 Client Secret 값. 이미 노출되어 비어있으면 {@code null}
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
*/
public String revealAndDeleteClientSecret(String orgId, String clientId) {
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
String secret = credential.getClientsecret();
if (StringUtils.isBlank(secret)) {
return null; // 이미 1회 노출되어 삭제됨
}
credential.setClientsecret(null); // 물리 삭제 (1회 제공)
credentialRepository.save(credential);
return secret;
}
/**
* API Key(Credential)를 즉시 삭제합니다.
* 승인 프로세스 없이 바로 삭제 처리됩니다.
@@ -31,4 +31,21 @@ public class ApprovalDTO {
private LocalDateTime createdDate;
private LocalDateTime approvalDate;
private String expectEndDate; //예상완료일 (yyyyMMdd)
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
public String getExpectEndDateDayOfWeek() {
if (expectEndDate == null || expectEndDate.length() < 8) {
return "";
}
try {
return java.time.LocalDate
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
.getDayOfWeek()
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
} catch (Exception e) {
return "";
}
}
}
@@ -3,8 +3,9 @@ package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.var;
import java.util.List;
import java.util.stream.Collectors;
@@ -18,12 +19,12 @@ public class ApiPermissionFilter {
if (isAuthenticated) {
// null 체크 추가
var user = SecurityUtil.getPortalAuthenticatedUser();
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user != null && user.getRoleCode() != null) {
roleCode = user.getRoleCode().toString();
}
var userOrg = SecurityUtil.getUserOrg();
PortalOrgDTO userOrg = SecurityUtil.getUserOrg();
if (userOrg != null) {
org = userOrg.getId();
}
@@ -44,12 +45,12 @@ public class ApiPermissionFilter {
if (isAuthenticated) {
// null 체크 추가
var user = SecurityUtil.getPortalAuthenticatedUser();
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user != null && user.getRoleCode() != null) {
roleCode = user.getRoleCode().toString();
}
var userOrg = SecurityUtil.getUserOrg();
PortalOrgDTO userOrg = SecurityUtil.getUserOrg();
if (userOrg != null) {
org = userOrg.getId();
}
@@ -3,31 +3,72 @@ package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.LocalDateTime;
import java.util.Optional;
@Component
public class AuthNumberStorage {
private static final String HASH_ALGORITHM = "HmacSHA256";
private final TwoFactorAuthRepository twoFactorAuthRepository;
/**
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
*/
@Value("${encryption.key:kjbank_portal_application_1357902}")
private String hashPepper;
@Autowired
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
this.twoFactorAuthRepository = twoFactorAuthRepository;
}
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
twoFactorAuthRepository.deleteAllByRecipient(recipientKey);
String lookupKey = hashRecipient(recipientKey);
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
auth.setRecipientKey(lookupKey);
twoFactorAuthRepository.save(auth);
}
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
return twoFactorAuthRepository.findByRecipient(recipientKey);
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
}
public void deleteAuthNumber(String recipientKey) {
twoFactorAuthRepository.deleteById(recipientKey);
twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
}
}
/**
* recipient 평문을 결정적 해시(HMAC-SHA256, 소문자 hex)로 변환한다.
* 동일 입력 → 동일 키이므로 저장/조회/정리에서 동등 매칭이 가능하다.
*/
private String hashRecipient(String recipient) {
if (recipient == null) {
return null;
}
try {
Mac mac = Mac.getInstance(HASH_ALGORITHM);
mac.init(new SecretKeySpec(hashPepper.getBytes(StandardCharsets.UTF_8), HASH_ALGORITHM));
byte[] digest = mac.doFinal(recipient.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("2FA recipient 해시 생성 실패", e);
}
}
}
@@ -1,26 +0,0 @@
package com.eactive.apim.portal.apps.commission.constant;
/**
* Application constants.
*/
public final class Constants {
public static final String SYSTEM_ACCOUNT = "system";
public static final String APIM_FOR_OBP_KEY = "x-obp-trust-system";
public static final String APIM_CODE_FOR_OBP_KEY = "x-obp-partnercode";
public static final String SMS_URL = "/api/messaging/sms";
public static final String COMMISSION_URL = "/api/billing/billing/findBillingForCondition";
public static final String COMMISSION_PRINT_URL = "/api/billing/billing/findBillingForCondition/print";
public static final String OBP_ORGANIZATION_URL = "/api/customer/findCustomerByBusinessManRegistrationNo/";
public static String LANGUAGE = "ko";
private Constants() {
}
}
@@ -1,320 +0,0 @@
package com.eactive.apim.portal.apps.commission.controller;
import com.eactive.apim.portal.apps.commission.constant.Constants;
import com.eactive.apim.portal.apps.commission.dto.CommissionDTO;
import com.eactive.apim.portal.apps.commission.dto.CommissionSearch;
import com.eactive.apim.portal.apps.commission.exception.ErrorUtil;
import com.eactive.apim.portal.apps.commission.service.ExternalService;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections.MapUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping(value = "/commission")
@Secured("ROLE_APP")
@RequiredArgsConstructor
public class CommissionController {
private final Logger log = LoggerFactory.getLogger(CommissionController.class);
private final ExternalService externalService;
private final PortalPropertyService portalPropertyService;
private final ObjectMapper objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
@GetMapping("/manage")
public String commissionManagePage(Model model) {
// 기본 정보 설정
model.addAttribute("organization", SecurityUtil.getUserOrg());
model.addAttribute("noOrgCode", StringUtils.isEmpty(SecurityUtil.getUserOrg().getOrgCode()));
return "apps/commission/commissionManage";
}
@GetMapping("/print/{year}/{month}")
public String commissionPrintPage(
@PathVariable("year") String year,
@PathVariable("month") String month,
Model model) {
CommissionSearch search = new CommissionSearch();
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
search.setYear(year);
search.setMonth(month);
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
if (result.hasError()) {
model.addAttribute("error", result.getError());
return "apps/commission/commissionPrint";
}
// Model에 데이터 바인딩
model.addAttribute("commission", result.getData());
model.addAttribute("organization", SecurityUtil.getUserOrg().getOrgName());
model.addAttribute("year", year);
model.addAttribute("month", month);
model.addAttribute("toDay", java.time.LocalDate.now().toString());
return "apps/commission/commissionPrint";
}
// ==================== API Endpoints ====================
@PostMapping("/print/check")
public ResponseEntity<?> checkPrintData(@RequestBody CommissionSearch search) {
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
if (StringUtils.isEmpty(search.getPartnerCode())) {
return ErrorUtil.create("fail.commission.needPartnerCode");
}
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_PRINT_URL);
if (result.hasError()) {
return ErrorUtil.create("fail.commission.print", result.getError());
}
return ResponseEntity.ok().body(result.getData());
}
@PostMapping()
public ResponseEntity<?> list(@RequestBody CommissionSearch search) {
search.setPartnerCode(SecurityUtil.getUserOrg().getOrgCode());
if (StringUtils.isEmpty(search.getPartnerCode())) {
return ErrorUtil.create("fail.commission.needPartnerCode");
}
CommissionResult result = fetchCommissionData(search, Constants.COMMISSION_URL);
if (result.hasError()) {
return ErrorUtil.create("fail.commission.list", result.getError());
}
return ResponseEntity.ok().body(result.getData());
}
// ==================== Private Methods ====================
/**
* 외부 API를 호출하여 수수료 데이터를 조회합니다.
*
* @param search 검색 조건
* @param apiPath API 경로 (Constants.COMMISSION_URL 또는 Constants.COMMISSION_PRINT_URL)
* @return 조회 결과
*/
private CommissionResult fetchCommissionData(CommissionSearch search, String apiPath) {
if (StringUtils.isEmpty(search.getPartnerCode())) {
return CommissionResult.error("파트너 코드가 없습니다.");
}
try {
String obmUrl = portalPropertyService.getPortalPropertiesAsMap("Portal").getOrDefault("obp.obm.url", "");
String url = obmUrl + apiPath;
// 첫 번째 API 호출
String result = externalService.getResponseHttpPost(url, toJson(search));
if (StringUtils.isEmpty(result)) {
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
}
HashMap<String, Object> returnObj = objectMapper.readValue(result, HashMap.class);
// 에러 응답 체크
String errorMessage = extractErrorMessage(returnObj);
if (errorMessage != null) {
return CommissionResult.error(errorMessage);
}
// Together 관련 추가 처리 (000002-01)
if (StringUtils.pathEquals(search.getPartnerCode(), "000002-01")) {
log.debug("Found Together : " + search.getPartnerCode());
CommissionSearch togetherSearch = new CommissionSearch();
togetherSearch.setPartnerCode("000002-02");
togetherSearch.setYear(search.getYear());
togetherSearch.setMonth(search.getMonth());
String togetherResult = externalService.getResponseHttpPost(url, toJson(togetherSearch));
if (StringUtils.isEmpty(togetherResult)) {
return CommissionResult.error("외부 서버 연결에 실패하였습니다.");
}
HashMap<String, Object> togetherReturnObj = objectMapper.readValue(togetherResult, HashMap.class);
log.debug("togetherLendingReturnObj : " + togetherReturnObj.toString());
String togetherErrorMessage = extractErrorMessage(togetherReturnObj);
if (togetherErrorMessage != null) {
return CommissionResult.error(togetherErrorMessage);
}
HashMap<String, Object> mergeMap = mergeCommissionData(returnObj, togetherReturnObj);
result = objectMapper.writeValueAsString(mergeMap);
}
CommissionDTO commissionDTO = objectMapper.readValue(result, CommissionDTO.class);
return CommissionResult.success(commissionDTO);
} catch (Exception e) {
log.warn(e.getMessage(), e);
return CommissionResult.error("수수료 조회에 실패하였습니다.");
}
}
/**
* 응답에서 에러 메시지를 추출합니다.
*/
private String extractErrorMessage(HashMap<String, Object> response) {
if (!StringUtils.isEmpty(response.get("error"))) {
HashMap<String, Object> error = (HashMap) response.get("error");
return error.get("message").toString();
}
return null;
}
private String toJson(CommissionSearch search) {
try {
Map<String, String> jsonMap = new HashMap<>();
jsonMap.put("partnerCode", search.getPartnerCode());
// 월을 두 자리로 패딩 (예: 1 -> 01, 12 -> 12)
String paddedMonth = String.format("%02d", Integer.parseInt(search.getMonth()));
jsonMap.put("targetOnMonth", search.getYear() + paddedMonth);
return objectMapper.writeValueAsString(jsonMap);
} catch (Exception e) {
log.warn("Failed to serialize CommissionSearch to JSON", e);
return "{}";
}
}
/**
* Safely parse double value from HashMap
* Handles both numeric types and string representations
*/
private double safeGetDouble(HashMap<String, Object> map, String key) {
Object value = map.get(key);
if (value == null) {
return 0.0;
}
try {
if (value instanceof Number) {
return ((Number) value).doubleValue();
} else if (value instanceof String) {
return Double.parseDouble((String) value);
}
return 0.0;
} catch (NumberFormatException e) {
log.warn("Failed to parse double value for key: {} with value: {}", key, value);
return 0.0;
}
}
public HashMap<String, Object> mergeCommissionData(HashMap<String, Object> o1, HashMap<String, Object> o2) {
HashMap<String, Object> mergeMap = new HashMap<String, Object>();
String o1Status = MapUtils.getString(o1, "status");
String o2Status = MapUtils.getString(o2, "status");
String resultStatus = null;
if (Integer.parseInt(o1Status) <= Integer.parseInt(o2Status)) {
resultStatus = o1Status;
} else {
resultStatus = o2Status;
}
double apiTotalValueSum = safeGetDouble(o1, "apiTotalValueSum") + safeGetDouble(o2, "apiTotalValueSum");
double apiSum = safeGetDouble(o1, "apiSum") + safeGetDouble(o2, "apiSum");
double apiTotalValueSum2 = safeGetDouble(o1, "apiTotalValueSum2") + safeGetDouble(o2, "apiTotalValueSum2");
double apiSum2 = safeGetDouble(o1, "apiSum2") + safeGetDouble(o2, "apiSum2");
double productTotalValueCountSum = safeGetDouble(o1, "productTotalValueCountSum") + safeGetDouble(o2, "productTotalValueCountSum");
double productTotalValueAmountSum = safeGetDouble(o1, "productTotalValueAmountSum") + safeGetDouble(o2, "productTotalValueAmountSum");
double productSum = safeGetDouble(o1, "productSum") + safeGetDouble(o2, "productSum");
double productTotalValueCountSum2 = safeGetDouble(o1, "productTotalValueCountSum2") + safeGetDouble(o2, "productTotalValueCountSum2");
double productTotalValueAmountSum2 = safeGetDouble(o1, "productTotalValueAmountSum2") + safeGetDouble(o2, "productTotalValueAmountSum2");
double productSum2 = safeGetDouble(o1, "productSum2") + safeGetDouble(o2, "productSum2");
double totalValueCountSum = safeGetDouble(o1, "totalValueCountSum") + safeGetDouble(o2, "totalValueCountSum");
double totalValueCountSum2 = safeGetDouble(o1, "totalValueCountSum2") + safeGetDouble(o2, "totalValueCountSum2");
double totalValueAmountSum = safeGetDouble(o1, "totalValueAmountSum") + safeGetDouble(o2, "totalValueAmountSum");
double totalValueAmountSum2 = safeGetDouble(o1, "totalValueAmountSum2") + safeGetDouble(o2, "totalValueAmountSum2");
double sum = safeGetDouble(o1, "sum") + safeGetDouble(o2, "sum");
double sum2 = safeGetDouble(o1, "sum2") + safeGetDouble(o2, "sum2");
List<Map> o1List = (ArrayList) o1.get("list");
List<Map> o2pList = (ArrayList) o2.get("list");
o1List.addAll(o2pList);
mergeMap.put("status", resultStatus);
mergeMap.put("apiTotalValueSum", String.format("%.2f", apiTotalValueSum));
mergeMap.put("apiSum", String.format("%.2f", apiSum));
mergeMap.put("apiTotalValueSum2", String.format("%.2f", apiTotalValueSum2));
mergeMap.put("apiSum2", String.format("%.2f", apiSum2));
mergeMap.put("productTotalValueCountSum", String.format("%.2f", productTotalValueCountSum));
mergeMap.put("productTotalValueAmountSum", String.format("%.2f", productTotalValueAmountSum));
mergeMap.put("productSum", String.format("%.2f", productSum));
mergeMap.put("productTotalValueCountSum2", String.format("%.2f", productTotalValueCountSum2));
mergeMap.put("productTotalValueAmountSum2", String.format("%.2f", productTotalValueAmountSum2));
mergeMap.put("productSum2", String.format("%.2f", productSum2));
mergeMap.put("totalValueCountSum", String.format("%.2f", totalValueCountSum));
mergeMap.put("totalValueCountSum2", String.format("%.2f", totalValueCountSum2));
mergeMap.put("totalValueAmountSum", String.format("%.2f", totalValueAmountSum));
mergeMap.put("totalValueAmountSum2", String.format("%.2f", totalValueAmountSum2));
mergeMap.put("sum", String.format("%.2f", sum));
mergeMap.put("sum2", String.format("%.2f", sum2));
mergeMap.put("list", o1List);
return mergeMap;
}
// ==================== Inner Classes ====================
/**
* 수수료 조회 결과를 담는 내부 클래스
*/
private static class CommissionResult {
private CommissionDTO data;
private String error;
static CommissionResult success(CommissionDTO data) {
CommissionResult result = new CommissionResult();
result.data = data;
return result;
}
static CommissionResult error(String message) {
CommissionResult result = new CommissionResult();
result.error = message;
return result;
}
boolean hasError() {
return error != null;
}
CommissionDTO getData() {
return data;
}
String getError() {
return error;
}
}
}
@@ -1,28 +0,0 @@
package com.eactive.apim.portal.apps.commission.dto;
import java.util.List;
import lombok.Data;
@Data
public class CommissionDTO {
private String status;
private String apiTotalValueSum;
private String apiSum;
private String apiTotalValueSum2;
private String apiSum2;
private String productTotalValueCountSum;
private String productTotalValueAmountSum;
private String productSum;
private String productTotalValueCountSum2;
private String productTotalValueAmountSum2;
private String productSum2;
private String totalValueCountSum;
private String totalValueCountSum2;
private String totalValueAmountSum;
private String totalValueAmountSum2;
private String sum;
private String sum2;
private List<CommissionList> list;
private String documentFormatNo;
}
@@ -1,32 +0,0 @@
package com.eactive.apim.portal.apps.commission.dto;
import lombok.Data;
/**
* Created by jskim on 17. 01. 26.
*
* @author jskim
*/
@Data
public class CommissionList {
private String paymentTargetName;
private String paymentTargetType;
private String value;
private String minimumAmount;
private String maximumAmount;
private String totalCount;
private String totalCount2;
private String totalValue;
private String totalValue2;
private String minimumAmount2;
private String maximumAmount2;
private String paymentBaseName;
private String paymentTimingName;
private String valueDivisionName;
private String sum;
private String sum2;
private String detailFeeTypeName;
private String value2;
private String changeReason2;
}
@@ -1,17 +0,0 @@
package com.eactive.apim.portal.apps.commission.dto;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@Data
public class CommissionSearch {
@ApiModelProperty(value = "대상년")
private String year;
@ApiModelProperty(value = "대상월")
private String month;
private String partnerCode;
}
@@ -1,27 +0,0 @@
package com.eactive.apim.portal.apps.commission.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
/**
* Created by ybsong on 16. 12. 7.
*
* @author ybsong
*/
public class ErrorUtil {
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message, String description) {
return new ResponseEntity<>(new ErrorVM(message, description), status);
}
public static ResponseEntity<ErrorVM> create(HttpStatus status, String message) {
return create(status, message, null);
}
public static ResponseEntity<ErrorVM> create(String message, String description) {
return create(HttpStatus.BAD_REQUEST, message, description);
}
public static ResponseEntity<ErrorVM> create(String message) {
return create(message, null);
}
}
@@ -1,43 +0,0 @@
package com.eactive.apim.portal.apps.commission.exception;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* View Model for transferring error message with a list of field errors.
*/
@Data
public class ErrorVM implements Serializable {
private static final long serialVersionUID = 1L;
private final String message;
private final String description;
private List<FieldErrorVM> fieldErrors;
public ErrorVM(String message) {
this(message, null);
}
public ErrorVM(String message, String description) {
this.message = message;
this.description = description;
}
public ErrorVM(String message, String description, List<FieldErrorVM> fieldErrors) {
this.message = message;
this.description = description;
this.fieldErrors = fieldErrors;
}
public void add(String objectName, String field, String message) {
if (fieldErrors == null) {
fieldErrors = new ArrayList<>();
}
fieldErrors.add(new FieldErrorVM(objectName, field, message));
}
}
@@ -1,33 +0,0 @@
package com.eactive.apim.portal.apps.commission.exception;
import java.io.Serializable;
public class FieldErrorVM implements Serializable {
private static final long serialVersionUID = 1L;
private final String objectName;
private final String field;
private final String message;
public FieldErrorVM(String dto, String field, String message) {
this.objectName = dto;
this.field = field;
this.message = message;
}
public String getObjectName() {
return objectName;
}
public String getField() {
return field;
}
public String getMessage() {
return message;
}
}
@@ -1,80 +0,0 @@
package com.eactive.apim.portal.apps.commission.service;
import com.eactive.apim.portal.apps.commission.constant.Constants;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.Map;
import javax.inject.Inject;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
/**
* 외부 API 호출 서비스
* RestTemplate 기반으로 외부 시스템과 HTTP 통신을 수행합니다.
*
* @author ybsong
* @since 2017-03-02
*/
@Service
@Transactional
@RequiredArgsConstructor
public class ExternalService {
private final Logger log = LoggerFactory.getLogger(ExternalService.class);
private final RestTemplate restTemplate;
private final PortalPropertyService portalPropertyService;
/**
* HTTP POST 요청을 통해 외부 API를 호출하고 응답을 받습니다.
*
* @param url 호출할 API URL
* @param payload 요청 본문 (JSON 문자열)
* @return API 응답 본문 (문자열), 오류 발생 시 null
*/
public String getResponseHttpPost(String url, String payload) {
try {
Map<String, String> portalProperties = portalPropertyService.getPortalPropertiesAsMap("Portal");
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set(Constants.APIM_FOR_OBP_KEY, portalProperties.getOrDefault("obp.api_key","APIMPT-0002-QVBJTVBULTAwMDI="));
headers.set(Constants.APIM_CODE_FOR_OBP_KEY, portalProperties.getOrDefault("obp.partner_code","100001-01"));
// HTTP 요청 엔티티 생성 (헤더 + 본문)
HttpEntity<String> requestEntity = new HttpEntity<>(payload, headers);
// POST 요청 실행
ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
// 응답 본문 반환
return response.getBody();
} catch (HttpStatusCodeException e) {
// 4xx, 5xx 에러더라도 응답 본문이 있으면 반환 (에러 메시지 포함)
String responseBody = e.getResponseBodyAsString();
log.warn("HTTP error from external API: url={}, status={}, body={}", url, e.getStatusCode(), responseBody);
if (responseBody != null && !responseBody.isEmpty()) {
return responseBody;
}
return null;
} catch (RestClientException e) {
log.error("Failed to call external API: url={}, error={}", url, e.getMessage(), e);
return null;
} catch (Exception e) {
log.error("Unexpected error during HTTP POST: url={}", url, e);
return null;
}
}
}
@@ -13,6 +13,7 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class FaqController {
@@ -47,4 +48,16 @@ public class FaqController {
return "apps/community/mainFaqList";
}
@GetMapping("/faq_view")
public String view(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return "redirect:/faq_list";
}
FaqDTO faq = faqFacade.getFaq(id);
model.addAttribute("faq", faq);
return "apps/community/mainFaqDetail";
}
}
@@ -7,4 +7,6 @@ import org.springframework.data.domain.Pageable;
public interface FaqFacade {
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
FaqDTO getFaq(String id);
}
@@ -34,4 +34,9 @@ public class FaqFacadeImpl implements FaqFacade {
return faqPage.map(faqMapper::map);
}
@Override
public FaqDTO getFaq(String id) {
return faqMapper.map(faqService.findById(id));
}
}
@@ -0,0 +1,13 @@
package com.eactive.apim.portal.apps.community.notice.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class IncidentAffectedApiDTO {
private String apiId;
private String apiName;
}
@@ -8,11 +8,18 @@ import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PortalNoticeDTO {
public static final String NOTICE_TYPE_NORMAL = "1";
public static final String NOTICE_TYPE_MAINTENANCE = "2";
public static final String NOTICE_TYPE_INCIDENT = "3";
private String id;
@NotEmpty(message = "제목을 입력해주세요.")
@@ -29,9 +36,33 @@ public class PortalNoticeDTO {
private boolean useYn;
private String fixYn;
private String noticeType;
private String inquirerName;
private LocalDateTime createdDate;
private LocalDateTime lastModifiedDate;
// ───── 장애/점검 연동 필드 ─────
private String summary;
private LocalDateTime startedAt;
private LocalDateTime endAt;
private String state;
private String previousState;
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
public boolean isIncidentType() {
return NOTICE_TYPE_INCIDENT.equals(noticeType);
}
public boolean isMaintenanceType() {
return NOTICE_TYPE_MAINTENANCE.equals(noticeType);
}
public boolean isIncidentOrMaintenance() {
return isIncidentType() || isMaintenanceType();
}
}
@@ -1,8 +1,12 @@
package com.eactive.apim.portal.apps.community.notice.service;
import com.eactive.apim.portal.apps.community.notice.dto.IncidentAffectedApiDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
@@ -12,7 +16,10 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service("portalNoticeFacade")
@RequiredArgsConstructor
@@ -20,12 +27,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private final PortalNoticeService portalNoticeService;
private final PortalNoticeMapper portalNoticeMapper;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
@Override
public List<PortalNoticeDTO> getLatestNotices() {
PortalNoticeSearch search = new PortalNoticeSearch();
Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdDate"));
// Pageable pageable = Pageable.ofSize(3);
Page<PortalNoticeDTO> notices = getNotices(search, pageable);
return notices.getContent();
}
@@ -36,13 +44,47 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
Specification<PortalNotice> spec = Specification.where(search.buildSpecification())
.and((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("useYn"),"Y"));
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, pageable);
// 상단고정(fixYn='Y') 우선 정렬 — 호출자의 sort 앞에 결합 (admin Service 와 일관)
Sort fixYnFirst = Sort.by(Sort.Direction.DESC, "fixYn");
Pageable fixedPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
fixYnFirst.and(pageable.getSort())
);
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, fixedPageable);
return noticesPage.map(portalNoticeMapper::map);
}
@Override
public PortalNoticeDTO getNotice(String id) {
PortalNotice portalNotice = portalNoticeService.getNotice(id);
return portalNoticeMapper.map(portalNotice);
PortalNoticeDTO dto = portalNoticeMapper.map(portalNotice);
populateIncident(dto);
return dto;
}
private void populateIncident(PortalNoticeDTO dto) {
if (!dto.isIncidentOrMaintenance()) {
dto.setAffectedApis(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
if (!incidentOpt.isPresent()) {
dto.setAffectedApis(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiDTO> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
.collect(Collectors.toList());
dto.setAffectedApis(apis);
}
}
@@ -40,6 +40,7 @@ public class PartnershipApplicationController {
model.addAttribute("partnership", new PartnershipApplicationDTO());
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
model.addAttribute("recentApplications", partnershipApplicationFacade.getMyRecentApplications());
return "apps/community/mainPartnershipForm";
}
@@ -54,7 +55,7 @@ public class PartnershipApplicationController {
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
// 성공 메시지 추가
redirectAttributes.addFlashAttribute("success", "사업제휴신청이 완료되었습니다.");
redirectAttributes.addFlashAttribute("success", "피드백/개선요청 등록이 완료되었습니다.");
httpServletRequest.getSession().removeAttribute("previousPage");
return "redirect:/partnership";
@@ -2,8 +2,10 @@ package com.eactive.apim.portal.apps.community.partnership.mapper;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
import com.eactive.apim.portal.common.mapper.CommonMapper;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
import org.springframework.stereotype.Component;
@@ -16,4 +18,8 @@ import org.springframework.stereotype.Component;
public interface PartnershipApplicationMapper {
PartnershipApplication toEntity(PartnershipApplicationDTO dto);
PartnershipApplicationSummaryDTO toSummaryDto(PartnershipApplication entity);
List<PartnershipApplicationSummaryDTO> toSummaryDtoList(List<PartnershipApplication> entities);
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
@@ -9,4 +10,10 @@ import org.springframework.stereotype.Repository;
@Repository
@EMSDataSource
public interface PartnershipApplicationRepository extends JpaRepository<PartnershipApplication, String>, JpaSpecificationExecutor<PartnershipApplication> {
/**
* 특정 작성자(createdBy)가 등록한 최근 3건을 최신순으로 조회한다.
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
*/
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
}
@@ -1,9 +1,16 @@
package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
import java.io.IOException;
import java.util.List;
public interface PartnershipApplicationFacade {
void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException;
/**
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
*/
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
}
@@ -1,18 +1,25 @@
package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipApplicationMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -21,13 +28,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
private final PartnershipApplicationService partnershipApplicationService;
private final PartnershipApplicationMapper partnershipApplicationMapper;
private final FileService fileService;
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
private final CommunityAdminNotifier portalAdminNotifier;
@Override
public void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException {
FileInfo file = null;
if (!partnershipApplicationDTO.getFiles().isEmpty()) {
if (partnershipApplicationDTO.getFiles() != null && !partnershipApplicationDTO.getFiles().isEmpty()) {
try {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
@@ -43,5 +52,25 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
partnershipApplication.setFileId(file.getFileId());
}
partnershipApplicationService.createPartnershipApplication(partnershipApplication);
// 개선요청/제휴 게시물 등록 시 portal-admin 에게 알림 (INQUIRY_CREATED 와 동일 패턴)
PortalAuthenticatedUser writer = SecurityUtil.getPortalAuthenticatedUser();
Map<String, Object> params = new HashMap<>();
params.put("partnershipId", partnershipApplication.getId());
params.put("bizSubject", partnershipApplication.getBizSubject());
if (writer != null) {
params.put("writerName", writer.getUserName());
}
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
}
@Override
public List<PartnershipApplicationSummaryDTO> getMyRecentApplications() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null) {
return Collections.emptyList();
}
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
return partnershipApplicationMapper.toSummaryDtoList(recent);
}
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -21,4 +22,12 @@ public class PartnershipApplicationService {
public void createPartnershipApplication(PartnershipApplication partnershipApplication) {
partnershipApplicationRepository.save(partnershipApplication);
}
/**
* 작성자 id 로 최근 등록 3건 조회(최신순).
*/
@Transactional(readOnly = true)
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
}
}
@@ -6,17 +6,24 @@ import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.validation.Valid;
@@ -29,9 +36,12 @@ public class InquiryController {
public static final String APPS_COMMUNITY_MAIN_INQUIRY_FORM = "apps/community/mainInquiryForm";
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
private final InquiryFacade inquiryFacade;
private final InquiryCommentFacade inquiryCommentFacade;
public InquiryController(InquiryFacade inquiryFacade) {
public InquiryController(InquiryFacade inquiryFacade,
InquiryCommentFacade inquiryCommentFacade) {
this.inquiryFacade = inquiryFacade;
this.inquiryCommentFacade = inquiryCommentFacade;
}
/**
@@ -42,8 +52,14 @@ public class InquiryController {
Model model) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
Page<InquiryDTO> inquiries = inquiryFacade.getInquiries(search, pageable, user);
List<String> ids = inquiries.getContent().stream()
.map(InquiryDTO::getId)
.collect(Collectors.toList());
Map<String, Long> commentCounts = inquiryCommentFacade.countActiveByInquiryIds(ids);
model.addAttribute("inquiries", inquiries.getContent());
model.addAttribute("page", inquiries);
model.addAttribute("commentCounts", commentCounts);
model.addAttribute("visibilityCeiling", inquiryFacade.getVisibilityCeiling());
return "apps/community/mainInquiryList";
}
@@ -57,18 +73,31 @@ public class InquiryController {
return REDIRECT_INQUIRY;
}
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
InquiryDTO inquiry = inquiryFacade.getAccessibleInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
model.addAttribute("inquiry", inquiry);
model.addAttribute("commentWritable", !"CLOSED".equals(inquiry.getInquiryStatus()));
return "apps/community/mainInquiryDetail";
}
/**
* 2-1. 조회수 증가 (sessionStorage dedup 통과 시 JS 가 POST 호출)
*/
@PostMapping("/{id}/view")
public ResponseEntity<Void> increaseViewCount(@PathVariable String id) {
inquiryFacade.incrementViewCount(SecurityUtil.getPortalAuthenticatedUser(), id);
return ResponseEntity.ok().build();
}
/**
* 3. inquiry 등록 페이지
*/
@GetMapping("/new")
public String newInquiryForm(Model model) {
model.addAttribute("inquiry", new InquiryDTO());
InquiryDTO inquiry = new InquiryDTO();
inquiry.setVisibility(VisibilityScope.ORG.name()); // 기본 공개범위: 법인공개
model.addAttribute("inquiry", inquiry);
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
addVisibilityOptions(model);
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
@@ -80,14 +109,23 @@ public class InquiryController {
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
model.addAttribute("inquiry", inquiry);
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
addVisibilityOptions(model);
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
/** 공개범위 상한에 따른 폼 옵션 노출 플래그. PRIVATE 는 항상 허용. */
private void addVisibilityOptions(Model model) {
VisibilityScope ceiling = inquiryFacade.getVisibilityCeiling();
model.addAttribute("allowAll", ceiling.width() >= VisibilityScope.ALL.width());
model.addAttribute("allowOrg", ceiling.width() >= VisibilityScope.ORG.width());
}
/**
* 5. inquiry 등록 요청
*/
@PostMapping
public String createInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
@RequestParam(value = "image", required = false) MultipartFile image,
RedirectAttributes redirectAttributes,
Model model) throws IOException {
if (bindingResult.hasErrors()) {
@@ -95,7 +133,7 @@ public class InquiryController {
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
inquiryFacade.createInquiry(inquiryDTO);
inquiryFacade.createInquiry(inquiryDTO, image);
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
return REDIRECT_INQUIRY;
@@ -106,11 +144,12 @@ public class InquiryController {
*/
@PostMapping("/edit")
public String updateInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
@RequestParam(value = "image", required = false) MultipartFile image,
RedirectAttributes redirectAttributes) throws IOException {
if (bindingResult.hasErrors()) {
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO);
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO, image);
redirectAttributes.addFlashAttribute("success", "Q&A 수정이 완료되었습니다.");
return "redirect:/inquiry/detail?id=" + inquiryDTO.getId();
}
@@ -5,13 +5,10 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.NotEmpty;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
@Data
@NoArgsConstructor
@@ -28,7 +25,7 @@ public class InquiryDTO {
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
private String inquiryDetail;
@Pattern(regexp = "^(PENDING|IN_PROGRESS|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
@Pattern(regexp = "^(PENDING|REVIEWING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
private String inquiryStatus;
private String inquirerName;
@@ -50,10 +47,26 @@ public class InquiryDTO {
private LocalDateTime lastModifiedDate;
private List<MultipartFile> files;
private String attachFile;
private String maskedInquirerName;
/** 목록 작성자 표기용 법인명(이름과 별도 줄 표기). */
private String inquirerOrgName;
/** 상세 작성자 표기용 "이름 (법인명)". */
private String inquirerDisplay;
/** 공개범위(ALL/ORG/PRIVATE). 미지정 시 서버에서 상한으로 clamp. */
private String visibility;
/** 공개범위 표기용 한글 라벨(전체공개/법인공개/비공개). */
private String visibilityLabel;
/** 조회수(표시용). */
private long viewCount;
/** 목록에서 비공개 게시물을 "비공개 게시물"로 흐리게 표기할지 여부(비-소유자). */
private boolean privatePlaceholder;
}
@@ -14,6 +14,9 @@ import org.springframework.stereotype.Component;
@Component
public interface InquiryMapper {
// visibility(공개범위)는 facade 에서 상한 clamp 후 수동 세팅, viewCount 는 서버가 관리
@Mapping(target = "visibility", ignore = true)
@Mapping(target = "viewCount", ignore = true)
Inquiry toEntity(InquiryDTO inquiry);
@Mapping(target = "inquirerName", source = "inquirer.userName")
@@ -3,21 +3,30 @@ package com.eactive.apim.portal.apps.community.qna.service;
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import java.io.IOException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.multipart.MultipartFile;
public interface InquiryFacade {
Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user);
/** 문의 공개범위 상한(폼 옵션 노출 제어용). */
VisibilityScope getVisibilityCeiling();
InquiryDTO getUserInquiry(PortalAuthenticatedUser user, String id);
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
void createInquiry(InquiryDTO inquiryDTO) throws IOException;
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException;
void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
void incrementViewCount(PortalAuthenticatedUser user, String id);
void deleteUserInquiry(PortalAuthenticatedUser user, String id);
}
@@ -6,28 +6,42 @@ 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.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.exception.InvalidFileException;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import org.apache.commons.io.FilenameUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class InquiryFacadeImpl implements InquiryFacade {
private final FileService fileService;
private static final Set<String> IMAGE_EXTENSIONS =
new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "gif"));
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final CommunityAdminNotifier inquiryAdminNotifier;
private final FileService fileService;
@Override
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
@@ -41,6 +55,8 @@ public class InquiryFacadeImpl implements InquiryFacade {
Page<Inquiry> inquiriesPage = inquiryService.getInquiries(spec, pageable);
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
return inquiriesPage.map(inquiry -> {
InquiryDTO dto = inquiryMapper.map(inquiry);
if (dto != null && dto.getInquirer() != null) {
@@ -49,8 +65,20 @@ public class InquiryFacadeImpl implements InquiryFacade {
inquiry.getInquirer().getId(),
user.getId()
));
PortalOrg org = inquiry.getInquirer().getPortalOrg();
if (org != null) {
dto.setInquirerOrgName(org.getOrgName());
}
dto.getInquirer().setUserName(null); // 원본 데이터 제거
}
// 비공개 게시물은 본인/같은 법인 관리자 외에는 "비공개 게시물"로만 노출
VisibilityScope effective = VisibilityScope.clampTo(inquiry.getVisibility(), ceiling);
dto.setVisibility(effective.name());
dto.setVisibilityLabel(effective.label());
if (effective == VisibilityScope.PRIVATE && !canReadPrivate(user, inquiry)) {
dto.setPrivatePlaceholder(true);
dto.setInquirySubject(null);
}
return dto;
});
}
@@ -80,62 +108,129 @@ public class InquiryFacadeImpl implements InquiryFacade {
}
@Override
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
FileInfo file = null;
if (!inquiryDTO.getFiles().isEmpty()) {
try {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
FileTypeContext.setFileType("qna");
file = fileService.createFile(inquiryDTO.getFiles());
} finally {
FileService.clearInternalUserContext();
}
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
InquiryDTO dto = inquiryMapper.map(inquiry);
if (dto != null) {
VisibilityScope effective = inquiryService.effectiveVisibility(inquiry);
dto.setVisibility(effective.name());
dto.setVisibilityLabel(effective.label());
}
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
if (file != null) {
inquiry.setAttachFile(file.getFileId());
if (dto != null && inquiry.getInquirer() != null) {
// 상세 작성자 표기: "이름 (법인명)" — 본인은 owner-bypass 로 원문
String maskedName = StringMaskingUtil.maskName(
inquiry.getInquirer().getUserName(),
inquiry.getInquirer().getId(),
user.getId());
dto.setInquirerDisplay(withOrgName(maskedName, inquiry.getInquirer()));
}
inquiry.setInquirer(SecurityUtil.getPortalAuthenticatedUser());
inquiryService.createInquiry(inquiry);
HashMap<String, Object> params = new HashMap<>();
params.put("inquiryId", inquiry.getId());
params.put("subject", inquiry.getInquirySubject());
return dto;
}
@Override
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException {
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
if (!inquiryDTO.getFiles().isEmpty() && !inquiryDTO.getFiles().get(0).isEmpty()) {
try {
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
FileTypeContext.setFileType("qna");
FileInfo file = fileService.createFile(inquiryDTO.getFiles());
if(file != null) {
inquiryDTO.setAttachFile(file.getFileId());
}
} finally {
FileService.clearInternalUserContext();
}
} else if (existingInquiry.getAttachFile() != null && inquiryDTO.getAttachFile() == null) {
fileService.deleteFile(inquiryDTO.getAttachFile());
inquiryDTO.setAttachFile(null);
public void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
inquiry.setInquirer(current);
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
if (hasFile(image)) {
inquiry.setAttachFile(storeImage(null, image));
}
inquiryService.createInquiry(inquiry);
Map<String, Object> params = new HashMap<>();
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("writerName", current.getUserName());
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
}
@Override
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
inquiry.setInquirer(existingInquiry.getInquirer());
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
// 이미지 교체 시 기존 fileId 로 업데이트, 없으면 기존 첨부 유지
if (hasFile(image)) {
inquiry.setAttachFile(storeImage(existingInquiry.getAttachFile(), image));
} else {
inquiry.setAttachFile(existingInquiry.getAttachFile());
}
inquiryService.updateInquiry(id, inquiry);
}
@Override
public void incrementViewCount(PortalAuthenticatedUser user, String id) {
// 접근 가능한 게시물만 조회수 증가
inquiryService.getAccessibleInquiry(user, id);
inquiryService.incrementViewCount(id);
}
@Override
public VisibilityScope getVisibilityCeiling() {
return inquiryService.getVisibilityCeiling();
}
@Override
public void deleteUserInquiry(PortalAuthenticatedUser user, String id) {
inquiryService.deleteMyInquiry(user, id);
}
// =====================================================================
// helpers
// =====================================================================
/** 요청 공개범위를 기본 ORG 로 두고 property 상한으로 clamp. */
private VisibilityScope clampedVisibility(String requested) {
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
VisibilityScope scope = VisibilityScope.fromString(requested, VisibilityScope.ORG);
return VisibilityScope.clampTo(scope, ceiling);
}
/** 비공개 게시물을 읽을 수 있는 사용자(작성자 본인 또는 같은 법인 관리자). */
private boolean canReadPrivate(PortalAuthenticatedUser user, Inquiry inquiry) {
PortalUser inquirer = inquiry.getInquirer();
if (inquirer == null || user == null) {
return false;
}
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
return true;
}
return user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
&& isSameOrg(user, inquirer);
}
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
PortalOrg userOrg = user.getPortalOrg();
PortalOrg inquirerOrg = inquirer.getPortalOrg();
return userOrg != null && inquirerOrg != null
&& userOrg.getId() != null
&& userOrg.getId().equals(inquirerOrg.getId());
}
/** "이름 (법인명)" 조합. 법인명이 없으면 이름만. */
private String withOrgName(String name, PortalUser inquirer) {
PortalOrg org = inquirer.getPortalOrg();
if (org != null && org.getOrgName() != null && !org.getOrgName().isEmpty()) {
return name + " (" + org.getOrgName() + ")";
}
return name;
}
private boolean hasFile(MultipartFile file) {
return file != null && !file.isEmpty();
}
/** 이미지 확장자 검증 후 단일 파일 저장, fileId 반환. */
private String storeImage(String existingFileId, MultipartFile image) throws IOException {
String ext = FilenameUtils.getExtension(image.getOriginalFilename());
if (ext == null || !IMAGE_EXTENSIONS.contains(ext.toLowerCase())) {
throw new InvalidFileException("이미지 파일(jpg, jpeg, png, gif)만 첨부할 수 있습니다.");
}
FileInfo fileInfo = fileService.createOrUpdateSingleFile(
existingFileId, image, image.getOriginalFilename(), true);
return fileInfo.getFileId();
}
}
@@ -4,7 +4,12 @@ package com.eactive.apim.portal.apps.community.qna.service;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.VisibilityScope;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
@@ -16,10 +21,19 @@ import org.springframework.transaction.annotation.Transactional;
public class InquiryService {
public static final String PENDING = "PENDING";
private final InquiryRepository inquiryRepository;
public InquiryService(InquiryRepository inquiryRepository) {
/** 문의 공개범위 상한/기본값 property. */
private static final String PROP_GROUP = "Portal";
private static final String PROP_VISIBILITY_CEILING = "djb.inquiry.default-visibility";
private static final String PROP_VISIBILITY_DESC = "Q&A 문의 공개범위 상한/기본값 (ALL/ORG/PRIVATE)";
private final InquiryRepository inquiryRepository;
private final PortalPropertyService portalPropertyService;
public InquiryService(InquiryRepository inquiryRepository,
PortalPropertyService portalPropertyService) {
this.inquiryRepository = inquiryRepository;
this.portalPropertyService = portalPropertyService;
}
/**
@@ -72,6 +86,7 @@ public class InquiryService {
inquiry.setInquirySubject(updatedInquiry.getInquirySubject());
inquiry.setInquiryDetail(updatedInquiry.getInquiryDetail());
inquiry.setAttachFile(updatedInquiry.getAttachFile());
inquiry.setVisibility(updatedInquiry.getVisibility());
return inquiryRepository.save(inquiry);
}
@@ -90,5 +105,77 @@ public class InquiryService {
return inquiryRepository.findByInquirerAndId(user, id).orElseThrow(() -> new NotFoundException("내 질문을 찾을 수 없습니다."));
}
/**
* 본인 또는 같은 법인 소속 이용자가 작성한 Q&A를 조회한다.
* - ROLE_USER: 본인 글만 접근 가능
* - ROLE_CORP_*: 같은 법인 소속 이용자가 작성한 글까지 접근 가능
*/
@Transactional(readOnly = true)
public Inquiry getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
Inquiry inquiry = getInquiry(id);
if (!isAccessibleByUser(user, inquiry)) {
throw new NotFoundException("문의글을 찾을 수 없습니다. id=" + id);
}
return inquiry;
}
private boolean isAccessibleByUser(PortalAuthenticatedUser user, Inquiry inquiry) {
PortalUser inquirer = inquiry.getInquirer();
if (inquirer == null || user == null) {
return false;
}
// 작성자 본인은 공개범위와 무관하게 항상 접근 가능
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
return true;
}
VisibilityScope effective = effectiveVisibility(inquiry);
switch (effective) {
case ALL:
// 전체공개: 로그인 사용자면 접근 가능(@Secured 로 이미 인증됨)
return true;
case ORG:
return isSameOrg(user, inquirer);
case PRIVATE:
// 비공개: 본인 외에는 같은 법인 관리자만 열람
return isSameOrg(user, inquirer)
&& user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER;
default:
return false;
}
}
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
PortalOrg userOrg = user.getPortalOrg();
PortalOrg inquirerOrg = inquirer.getPortalOrg();
return userOrg != null && inquirerOrg != null
&& userOrg.getId() != null
&& userOrg.getId().equals(inquirerOrg.getId());
}
/**
* 게시물의 유효 공개범위 = 저장값을 property 상한으로 clamp 한 값.
* (property 가 항상 우선하므로 저장값이 옛 상한이라 넓더라도 재-clamp)
*/
@Transactional(readOnly = true)
public VisibilityScope effectiveVisibility(Inquiry inquiry) {
return VisibilityScope.clampTo(inquiry.getVisibility(), getVisibilityCeiling());
}
/** PTL_PROPERTY 에 설정된 공개범위 상한(없으면 ORG). */
@Transactional(readOnly = true)
public VisibilityScope getVisibilityCeiling() {
String value = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_VISIBILITY_CEILING, VisibilityScope.ORG.name(), PROP_VISIBILITY_DESC);
return VisibilityScope.fromString(value, VisibilityScope.ORG);
}
/**
* 조회수 1 증가. sessionStorage dedup 을 통과한 POST 요청에서만 호출된다.
*/
public void incrementViewCount(String id) {
Inquiry inquiry = getInquiry(id);
inquiry.increaseViewCount();
inquiryRepository.save(inquiry);
}
}
@@ -152,25 +152,6 @@ public class AccountRecoveryController {
}
}
@PostMapping("/resend_verification_email")
public ResponseEntity<Map<String, String>> resendVerificationEmail(@RequestParam String loginId) {
Map<String, String> response = new HashMap<>();
try {
portalUserAuthService.resendVerificationEmail(loginId);
response.put("success", "인증 이메일이 재발송되었습니다.");
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
} catch (Exception e) {
response.put("error", "이메일 발송 중 오류가 발생했습니다.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
@GetMapping("/dormant_account")
public String showDormantAccountPage(HttpSession session,Model model) {
@@ -0,0 +1,130 @@
package com.eactive.apim.portal.apps.session.controller;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
*
* <ul>
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/session")
@RequiredArgsConstructor
public class SessionApiController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService;
/**
* 로그인 전 중복 세션 확인
*/
@PostMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
Map<String, Object> result = new HashMap<>();
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
if (activeSession.isPresent()) {
UserSession session = activeSession.get();
result.put("duplicateSession", true);
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
} else {
result.put("duplicateSession", false);
}
return ResponseEntity.ok(result);
}
/**
* 세션 상태 폴링 (인증 필요)
*/
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> getSessionStatus(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("valid", false);
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
boolean forceLoggedOut = userSessionService.isForceLoggedOut(sessionId);
if (forceLoggedOut) {
result.put("valid", false);
result.put("remainingSeconds", 0);
} else {
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("valid", remaining > 0);
result.put("remainingSeconds", remaining);
}
return ResponseEntity.ok(result);
}
/**
* 하트비트 - lastAccessTime 갱신 (세션 연장)
*/
@PostMapping("/heartbeat")
public ResponseEntity<Map<String, Object>> heartbeat(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
userSessionService.updateLastAccessTime(sessionId);
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("remainingSeconds", remaining);
return ResponseEntity.ok(result);
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
*/
private String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.session.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 사용자 세션 추적 엔티티 (EMS 스키마, PTL_USER_SESSION)
*
* <p>화면 세션 타이머/유휴 자동 로그아웃 및 중복로그인 강제 로그아웃에 사용된다.
* {@code lastAccessTime} + 타임아웃(분) 이 세션 만료의 기준이며, {@code forceLogout='Y'} 인 세션은
* 다음 요청 시 {@link com.eactive.apim.portal.apps.session.filter.SessionValidationFilter} 가 강제 로그아웃한다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "PTL_USER_SESSION")
public class UserSession {
@Id
@Column(name = "SESSION_ID", length = 128, nullable = false)
private String sessionId;
@Column(name = "USER_ID", length = 36, nullable = false)
private String userId;
@Column(name = "LOGIN_ID", length = 200, nullable = false)
private String loginId;
@Column(name = "LOGIN_TIME", nullable = false)
private LocalDateTime loginTime;
@Column(name = "LAST_ACCESS_TIME", nullable = false)
private LocalDateTime lastAccessTime;
@Column(name = "IP_ADDRESS", length = 45)
private String ipAddress;
@Column(name = "USER_AGENT", length = 500)
private String userAgent;
@Column(name = "FORCE_LOGOUT", length = 1, nullable = false)
@Builder.Default
private String forceLogout = "N";
}
@@ -0,0 +1,124 @@
package com.eactive.apim.portal.apps.session.filter;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* 인증 사용자 요청마다 DB 세션 상태를 검증한다.
*
* <ol>
* <li>강제 로그아웃(forceLogout='Y') 감지 시 세션 무효화 후 {@code /login?forceLogout=true}</li>
* <li>잔여 시간 만료 시 세션 무효화 후 {@code /login?expired=true}</li>
* <li>그 외에는 60초 스로틀로 lastAccessTime 갱신 (실제 활동 반영)</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionValidationFilter extends OncePerRequestFilter {
private static final String LAST_DB_UPDATE_ATTR = "SESSION_LAST_DB_UPDATE";
private static final long DB_UPDATE_THROTTLE_MS = 60_000; // 60초 스로틀링
private static final List<String> EXCLUDED_PREFIXES = Arrays.asList(
"/api/session/",
"/login",
"/actionLogin.do",
"/actionLogout.do",
"/css/",
"/js/",
"/img/",
"/plugins/",
"/fonts/",
"/favicon.ico",
"/error",
"/health"
);
private final UserSessionService userSessionService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty()) {
path = path.substring(contextPath.length());
}
// 제외 경로 체크
if (isExcludedPath(path)) {
filterChain.doFilter(request, response);
return;
}
// 인증되지 않은 요청은 통과
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
filterChain.doFilter(request, response);
return;
}
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
filterChain.doFilter(request, response);
return;
}
String sessionId = httpSession.getId();
// 1. 강제 로그아웃 체크 (중복 로그인)
if (userSessionService.isForceLoggedOut(sessionId)) {
log.info("강제 로그아웃 감지 - sessionId: {}", sessionId);
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?forceLogout=true");
return;
}
// 2. 세션 만료 체크 (DB 레코드 없거나 잔여 0)
long remaining = userSessionService.getRemainingSeconds(sessionId);
if (remaining <= 0) {
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?expired=true");
return;
}
// 3. lastAccessTime 갱신 (60초 스로틀링)
Long lastUpdate = (Long) httpSession.getAttribute(LAST_DB_UPDATE_ATTR);
long now = System.currentTimeMillis();
if (lastUpdate == null || (now - lastUpdate) > DB_UPDATE_THROTTLE_MS) {
userSessionService.updateLastAccessTime(sessionId);
httpSession.setAttribute(LAST_DB_UPDATE_ATTR, now);
}
filterChain.doFilter(request, response);
}
private boolean isExcludedPath(String path) {
for (String prefix : EXCLUDED_PREFIXES) {
if (path.startsWith(prefix)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,33 @@
package com.eactive.apim.portal.apps.session.repository;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
List<UserSession> findByLoginId(String loginId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId")
int forceLogoutAllSessions(@Param("loginId") String loginId);
@Modifying
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
@Modifying
@Query("UPDATE UserSession s SET s.lastAccessTime = :now WHERE s.sessionId = :sessionId")
int updateLastAccessTime(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
}
@@ -0,0 +1,191 @@
package com.eactive.apim.portal.apps.session.service;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserSessionService {
private static final String PROPERTY_GROUP = "Portal";
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
private static final String DEFAULT_KEEPALIVE_ENABLED = "false";
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
private static final long CLEANUP_MARGIN_MINUTES = 1;
private final UserSessionRepository userSessionRepository;
private final PortalPropertyService portalPropertyService;
/**
* 활성 세션 존재 여부 확인 (만료되지 않고 강제 로그아웃되지 않은 세션)
*/
@Transactional(readOnly = true)
public Optional<UserSession> getActiveSession(String loginId) {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);
List<UserSession> sessions = userSessionRepository.findByLoginId(loginId);
return sessions.stream()
.filter(s -> "N".equals(s.getForceLogout()))
.filter(s -> s.getLastAccessTime().isAfter(cutoff))
.findFirst();
}
/**
* 새 세션 등록
*/
@Transactional
public void registerSession(String sessionId, String userId, String loginId, String ipAddress, String userAgent) {
UserSession session = UserSession.builder()
.sessionId(sessionId)
.userId(userId)
.loginId(loginId)
.loginTime(LocalDateTime.now())
.lastAccessTime(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(truncate(userAgent, 500))
.forceLogout("N")
.build();
userSessionRepository.save(session);
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
}
/**
* 다른 세션 강제 로그아웃 플래그 설정 (중복 로그인 방지)
*/
@Transactional
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
if (count > 0) {
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 해당 사용자의 모든 세션에 강제 로그아웃 플래그 설정.
* 관리자의 권한 변경(위임/회수/소속제외)을 대상 사용자의 활성 세션에 반영하기 위해 사용한다.
*/
@Transactional
public void forceLogoutAllSessions(String loginId) {
int count = userSessionRepository.forceLogoutAllSessions(loginId);
if (count > 0) {
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 현재 세션의 강제 로그아웃 여부 확인
*/
@Transactional(readOnly = true)
public boolean isForceLoggedOut(String sessionId) {
return userSessionRepository.findById(sessionId)
.map(s -> "Y".equals(s.getForceLogout()))
.orElse(false);
}
/**
* 세션 삭제
*/
@Transactional
public void removeSession(String sessionId) {
if (userSessionRepository.existsById(sessionId)) {
userSessionRepository.deleteById(sessionId);
log.debug("세션 삭제 - sessionId: {}", sessionId);
}
}
/**
* 마지막 접근 시간 갱신 (세션 연장)
*/
@Transactional
public void updateLastAccessTime(String sessionId) {
userSessionRepository.updateLastAccessTime(sessionId, LocalDateTime.now());
}
/**
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
*/
public int getSessionTimeoutMinutes() {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP,
PROPERTY_NAME,
DEFAULT_TIMEOUT_MINUTES,
"세션 타임아웃 시간 (분)"
);
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
}
}
/**
* DB(PortalProperty)에서 세션 유지(타임아웃 무시) 기능 활성화 여부 조회.
* ⚠️ prod 가드는 포함하지 않는다 — prod 차단은 호출부(GlobalControllerAdvice)에서 {@code !prod && isKeepAliveEnabled()}로 처리.
*/
public boolean isKeepAliveEnabled() {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP,
KEEPALIVE_PROPERTY_NAME,
DEFAULT_KEEPALIVE_ENABLED,
"세션 유지(타임아웃 무시) 기능 활성화 여부 (true/false, 비운영 전용)"
);
return value != null && Boolean.parseBoolean(value.trim());
}
/**
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
*/
@Transactional(readOnly = true)
public long getRemainingSeconds(String sessionId) {
int timeoutMinutes = getSessionTimeoutMinutes();
return userSessionRepository.findById(sessionId)
.map(s -> {
LocalDateTime expireTime = s.getLastAccessTime().plusMinutes(timeoutMinutes);
long remaining = Duration.between(LocalDateTime.now(), expireTime).getSeconds();
return Math.max(0, remaining);
})
.orElse(0L);
}
/**
* 만료 세션 정리 (1분 주기).
* 논리적 만료(lastAccessTime + 타임아웃)가 지난 세션을 소량 마진({@value #CLEANUP_MARGIN_MINUTES}분) 후 삭제한다.
* heartbeat/활동이 멈춰 더 이상 갱신되지 않는 세션(닫힌 탭·크래시·네트워크 단절 등)을 신속히 제거한다.
*/
@Scheduled(fixedRate = 60000)
@Transactional
public void cleanupExpiredSessions() {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes + CLEANUP_MARGIN_MINUTES);
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
if (deleted > 0) {
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
}
}
private String truncate(String value, int maxLength) {
if (value == null) {
return null;
}
return value.length() > maxLength ? value.substring(0, maxLength) : value;
}
}
@@ -1,30 +1,19 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.dto.*;
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.PhoneNumberUtil;
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;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.var;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
@@ -34,14 +23,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
@Secured("ROLE_ACCOUNT")
@RequiredArgsConstructor
@@ -54,6 +48,7 @@ public class AccountController {
private final AgreementsFacade agreementsFacade;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
private final UserSessionService userSessionService;
@PostMapping("/confirm_password")
@@ -106,6 +101,12 @@ public class AccountController {
session.removeAttribute("success");
session.removeAttribute("redirectUrl");
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
@@ -143,8 +144,8 @@ public class AccountController {
// ROLE_USER인 경우 초대 여부 확인
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getEmailAddr(), InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
mav.addObject("hasPendingInvitation", true);
@@ -252,7 +253,7 @@ public class AccountController {
@GetMapping("/mypage/org-transfer")
public String showOrgTransferPage(Model model) {
try {
var authenticatedUser = SecurityUtil.getPortalAuthenticatedUser();
PortalAuthenticatedUser authenticatedUser = SecurityUtil.getPortalAuthenticatedUser();
PortalUserDTO user = userFacade.findById(authenticatedUser.getId());
if (user.getRoleCode() != PortalUserEnums.RoleCode.ROLE_USER) {
@@ -263,7 +264,7 @@ public class AccountController {
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("registrationType", "corporate");
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("registrationType", "corporate");
return "apps/mypage/orgTransfer";
@@ -282,7 +283,7 @@ public class AccountController {
HttpServletResponse response) {
Map<String, Object> result = new HashMap<>();
try {
var currentUser = SecurityUtil.getPortalAuthenticatedUser();
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
if (currentUser.getRoleCode() != PortalUserEnums.RoleCode.ROLE_USER) {
result.put("status", "ERROR");
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import org.springframework.web.bind.annotation.PostMapping;
@@ -22,18 +23,22 @@ public class AuthController {
private final AuthFacade authFacade;
private final CellPhoneValidator cellPhoneValidator;
private final EmailValidator emailValidator;
private final PortalUserService portalUserService;
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) {
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator,
PortalUserService portalUserService) {
this.authFacade = authFacade;
this.cellPhoneValidator = cellPhoneValidator;
this.emailValidator = emailValidator;
this.portalUserService = portalUserService;
}
@PostMapping("/request_auth_number")
public ValidationResponse requestAuthNumber(
@RequestParam(required = false) String mobileNumber,
@RequestParam(required = false) String purpose,
HttpSession session) {
ValidationResponse response = new ValidationResponse();
@@ -44,6 +49,16 @@ public class AuthController {
return response;
}
// 회원가입 흐름에서는 인증번호 발송 전에 휴대폰 번호 중복을 미리 검증한다.
// (중복 번호를 인증까지 마친 뒤 가입 단계에서야 거절되는 것을 방지)
// mobileNumber 는 저장 포맷과 동일한 하이픈 포함 형태이므로 sanitize 전 값으로 조회한다.
if ("signup".equals(purpose) && portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(mobileNumber)) {
response.setValid(false);
response.setMessage("이미 가입된 휴대폰 번호입니다.");
return response;
}
String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", ""));
// 이전 세션 데이터 정리
@@ -29,7 +29,8 @@ public class OrgRegisterController {
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_ORG_REGISTER;
}
@@ -74,6 +75,7 @@ public class OrgRegisterController {
if (response.isValid()) {
model.addAttribute("message", response.getMessage());
model.addAttribute("registrationType", "corporate");
return MAIN_REGISTER_RESULT;
} else {
setModelForErrorOrg(model, orgDTO, response.getMessage());
@@ -91,6 +93,7 @@ public class OrgRegisterController {
model.addAttribute("portalOrg", orgDTO);
model.addAttribute("error", errorMessage);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
}
}
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
import com.eactive.apim.portal.common.dto.ResponseDTO;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.PostMapping;
@@ -18,12 +19,16 @@ import org.springframework.web.bind.annotation.RestController;
public class UserManRestController {
private final UserManFacade userManFacade;
private final CellPhoneValidator cellPhoneValidator;
// 1. Cancel invitation
@PostMapping("/cancel-invitation")
public ResponseDTO cancelInvitation(@RequestBody PortalUserDTO user) {
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "초대가 취소되었습니다.");
boolean notifyConsent = user.isNotifyConsent();
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId(), notifyConsent);
return new ResponseDTO(200, "SUCCESS", notifyConsent
? "초대가 취소되었습니다.<br>취소 알림이 발송되었습니다."
: "초대가 취소되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
}
// 2. Inactivate user
@@ -40,25 +45,25 @@ public class UserManRestController {
return new ResponseDTO(200, "SUCCESS", "사용자가 활성화되었습니다.");
}
//5. register new user
//5. register new user (휴대폰 번호 기반 초대)
@PostMapping("/invite")
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
String email = newUser.getEmailAddr();
String mobile = newUser.getMobileNumber();
// 이메일 형식 검증
if (email == null || email.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
// 휴대폰 번호 검증
if (mobile == null || mobile.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "휴대폰 번호를 입력해주세요.");
}
if (!cellPhoneValidator.isValid(mobile, null)) {
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", "올바른 이메일 형식을 입력해주세요.");
}
boolean notifyConsent = newUser.isNotifyConsent();
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), mobile, notifyConsent);
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
return new ResponseDTO(200, "SUCCESS", notifyConsent
? "초대 메시지가 발송되었습니다."
: "초대가 등록되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
}
@PostMapping("/change-role")
@@ -69,6 +74,20 @@ public class UserManRestController {
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
}
// 관리자 -> 이용자 변경 (본인 제외)
@PostMapping("/revoke-manager")
public ResponseDTO revokeManager(@RequestBody PortalUserDTO user) {
userManFacade.revokeManager(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "이용자로 변경되었습니다.");
}
// 소속 제외 -> 개인이용자로 전환
@PostMapping("/remove-from-org")
public ResponseDTO removeFromOrg(@RequestBody PortalUserDTO user) {
userManFacade.removeFromOrg(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "소속에서 제외되어 개인이용자로 변경되었습니다.");
}
// 6. Resend invitation
@PostMapping("/resend-invitation")
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
@@ -4,11 +4,14 @@ import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
@@ -16,11 +19,15 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
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.repository.PortalUserRepository;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
@@ -50,6 +57,7 @@ public class UserRegisterController {
private final PortalUserService portalUserService;
private final UserRegisterFacade userRegisterFacade;
private final AuthFacade authFacade;
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final AgreementsFacade agreementsFacade;
@@ -57,6 +65,7 @@ public class UserRegisterController {
private final UserInvitationRepository userInvitationRepository;
private final EncryptionUtil encryptionUtil;
private final AgreementValidator agreementValidator;
private final PortalUserAuthService portalUserAuthService;
@GetMapping("/signup")
public String showSignupSelection() {
@@ -67,8 +76,6 @@ public class UserRegisterController {
public String getUserAgreement(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, Model model) {
boolean isInvited = false;
String orgName = null;
String email = null;
String[] parts = null;
if (invitationToken != null) {
// 8글자 토큰 사용
@@ -80,29 +87,23 @@ public class UserRegisterController {
String orgId = invitation.get().getOrgId();
Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
orgName = org.map(PortalOrg::getOrgName).orElse(null);
email = invitation.get().getInvitationEmail();
parts = email.split("@");
}
}
model.addAttribute("registrationType", isInvited ? "corporate" : "personal");
model.addAttribute("isInvited", isInvited);
model.addAttribute("orgName", orgName);
model.addAttribute("email", email);
model.addAttribute("loginId", email);
if (parts != null) {
model.addAttribute("emailId", parts[0]);
model.addAttribute("domain", parts[1]);
} else {
model.addAttribute("emailId", "");
model.addAttribute("domain", "");
}
// 휴대폰 번호 기반 초대 전환: 이메일/로그인ID 가입자가 직접 입력한다
model.addAttribute("email", "");
model.addAttribute("loginId", "");
model.addAttribute("emailId", "");
model.addAttribute("domain", "");
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_USER_REGISTER;
@@ -138,8 +139,21 @@ public class UserRegisterController {
}
// 성공 PRG 패턴 적용: 결과 페이지로 리다이렉트
session.removeAttribute("invitationToken");
// 개인 가입자 이메일 인증 대상(READY 상태) 회원가입 직후 바로 이메일 인증 단계로 이동
if (invitationToken == null) {
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
if (registered.isPresent()
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
return "redirect:/signup/verification-email";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return "redirect:/signup/complete";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
return "redirect:/signup/complete/corporate";
} catch (Exception e) {
setModelForError(session, model, agreement, portalUserRegistrationDTO,
"처리 중 오류가 발생했습니다: " + e.getMessage());
@@ -169,6 +183,57 @@ public class UserRegisterController {
return MAIN_EMAIL_COMPLETED;
}
/**
* 회원가입 직후 이메일 인증 페이지.
* 가입 단계에서 세션에 저장된 이메일이 있어야 진입 가능하다.
*/
@GetMapping("/signup/verification-email")
public String showSignupVerificationEmail(HttpSession session, Model model) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return "redirect:/login";
}
model.addAttribute("email", email);
return "apps/register/signupVerificationEmail";
}
/**
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
*/
@PostMapping("/signup/send-verification-code")
public ResponseEntity<ValidationResponse> sendSignupVerificationCode(HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
ValidationResponse response = authFacade.requestAuth(email, "EMAIL");
return ResponseEntity.ok(response);
}
/**
* 회원가입 이메일 인증코드 검증. 성공 사용자 상태를 READY ACTIVE 활성화한다.
*/
@PostMapping("/signup/verify-email-code")
public ResponseEntity<ValidationResponse> verifySignupEmailCode(@RequestBody Map<String, String> request,
HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
String code = request.get("code");
ValidationResponse response = authFacade.verifyAuthNumber(email, code);
if (response.isValid()) {
PortalUser user = portalUserService.findByEmailAddr(email);
portalUserService.activateUser(user.getId());
session.removeAttribute("signupVerificationEmail");
}
return ResponseEntity.ok(response);
}
@GetMapping("/signup/decision")
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
HttpSession session,
@@ -181,6 +246,11 @@ public class UserRegisterController {
String decodedToken = decodeInvitationToken(invitationToken);
session.setAttribute("decisionToken", decodedToken);
// 이미 로그인된 상태면 수락 화면으로 직행
if (SecurityUtil.isAuthenticated()) {
return "redirect:/signup/decision_process";
}
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
@@ -188,9 +258,9 @@ public class UserRegisterController {
return "redirect:/";
}
// 이메일 사용자 정보 조회
String email = invitation.get().getInvitationEmail();
Optional<PortalUser> portalUser = portalUserRepository.findPortalUserByEmailAddr(email);
// 휴대폰 번호 사용자 정보 조회
String mobile = invitation.get().getInvitationMobile();
Optional<PortalUser> portalUser = portalUserRepository.findAllByMobileNumber(mobile).stream().findFirst();
if (!portalUser.isPresent()) {
model.addAttribute("error", "사용자 정보를 찾을 수 없습니다.");
@@ -219,13 +289,14 @@ public class UserRegisterController {
public String showDecisionProcessPage(HttpSession session, Model model) {
session.removeAttribute("decisionToken");
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
if (!invitation.isPresent()) {
model.addAttribute("error", "유효하지 않은 초대입니다");
return "redirect:/";
}
if (!SecurityUtil.getPortalAuthenticatedUser().getLoginId().equals(invitation.get().getInvitationEmail())) {
if (mobile == null || !mobile.equals(invitation.get().getInvitationMobile())) {
model.addAttribute("error", "유효하지 않은 초대입니다");
return "redirect:/";
}
@@ -241,7 +312,9 @@ public class UserRegisterController {
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("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
@@ -260,7 +333,7 @@ public class UserRegisterController {
agreementValidator.validate(agreement, bindingResult);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
@@ -270,13 +343,20 @@ public class UserRegisterController {
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
} else {
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
// 수락 성공 재로그인 없이 본인 세션 권한 갱신 (ROLE_USER ROLE_CORP_USER)
if ("accept".equalsIgnoreCase(action) && response.isValid()) {
portalUserAuthService.reloadCurrentAuthentication();
}
// 초대 처리 완료 세션에서 초대 관련 속성 제거
session.removeAttribute("pendingInvitation");
session.removeAttribute("pendingInvitationToken");
@@ -294,24 +374,6 @@ public class UserRegisterController {
return "redirect:/";
}
@GetMapping("/validate_email")
public String validateUserEmail(@RequestParam(name = "token", required = false) String token) {
String decodedToken = new String(Base64.decode(token));
try {
String decToken = encryptionUtil.decrypt(decodedToken);
String[] tokens = decToken.split(":");
boolean approvalRequired = portalUserService.activateUser(tokens[1]);
if (approvalRequired) {
return "apps/register/emailValidationResultCorpManager";
} else {
return "apps/register/emailValidationResultUser";
}
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException |
BadPaddingException e) {
throw new IllegalArgumentException("인증 정보 오류. 관리자 문의하세요.");
}
}
// 에러 발생 모델 세팅을 위한 private 메서드
private void setModelForError(HttpSession session, Model model, UserAgreementDTO agreement,
@@ -325,7 +387,8 @@ public class UserRegisterController {
model.addAttribute("agreement", agreement);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("msgType", "sms");
}
@@ -437,10 +500,10 @@ public class UserRegisterController {
session.removeAttribute("invitationCodeAttempts");
session.removeAttribute("invitationCodeLockoutUntil");
// 기존 사용자 여부 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
// 기존 사용자 여부 확인 (휴대폰 번호 기반)
boolean existingUser = !portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile()).isEmpty();
if (existingUser.isPresent()) {
if (existingUser) {
// 기존 사용자: 초대 수락 페이지로 이동
return "redirect:/signup/decision?invitation=" + normalizedCode;
} else {

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