108 Commits

Author SHA1 Message Date
Rinjae a92fc85730 포탈 메뉴 관리 기능 추가
- PortalMenuCacheClient 클래스 작성
- JSP 화면 portalMenuMan.jsp 추가 및 렌더링 로직 구현
- PortalMenuManController 클래스 작성
2026-08-05 14:19:04 +09:00
Rinjae 317634b5b7 Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-08-05 10:44:38 +09:00
Rinjae 27fa79ddd9 자동 장애 탐지 초안 양식 추가
- apistatus-draft.yml 파일 및 관련 로직 추가
- SnakeYAML 의존성 추가 및 안전한 yml 파싱 구현
- 자동 탐지 공지 제목·본문·타임라인 문구 분리 및 설정 관리
2026-08-05 10:44:31 +09:00
eastargh ccc55fd015 장애,지연 판단기준 >에서 >= 으로 변경
eapim-admin CI / build (push) Has been cancelled
2026-08-05 10:04:05 +09:00
Rinjae 3bf8370d0c API 상태 판정 로직 개선
eapim-admin CI / build (push) Has been cancelled
- 오류율 및 지연 판정 쿼리 조건 수정
- 상태 판정 진단 로그 메시지 포맷 및 로직 업데이트
2026-08-04 19:18:43 +09:00
eastargh 46bd79bc36 Merge branch 'master' of https://git.eactive.synology.me:8090/eapim/djbank/eapim-admin.git
eapim-admin CI / build (push) Has been cancelled
2026-08-04 17:59:08 +09:00
eastargh cdc1a6f342 IIM delete 연동 수정 2026-08-04 17:58:24 +09:00
Rinjae 42d7fa977e 오류율 계산 및 로깅 로직 추가
eapim-admin CI / build (push) Has been cancelled
- ApiErrorRateRow 인터페이스 및 쿼리 메서드 추가
- 오류율 진단 로그 로직 구현 및 개발 모드 조건 처리
- 로그 설정(DEBUG) 변경 및 ApiStatusRepository 쿼리 개선
2026-08-04 16:19:54 +09:00
Rinjae cc0ef13d5d - IncidentTimelineUI VO 추가 - 장애 타임라인 화면 구현 지원
eapim-admin CI / build (push) Has been cancelled
- PortalNoticeManService 장애 상태 타임라인 로직 추가
- 메인 CSS에 타임라인 스타일 추가
2026-08-04 13:10:31 +09:00
Rinjae 6604f7c9e8 Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-08-03 17:03:41 +09:00
Rinjae cd8f826ba5 자동 장애 탐지 및 공지 처리 개선
- 점검 판정 로직에 KIND='MAINTENANCE' 조건 추가
- 자동 등록모드 OFF/DRAFT/PUBLISH 지원 및 기본값 적용
- 장애/지연 병합 및 자동 공지 후 게시 상태 동기화
2026-08-03 17:03:35 +09:00
eastargh 031796dac4 IIM 삭제 연동시 결과메세지 수정
eapim-admin CI / build (push) Has been cancelled
2026-08-03 14:32:39 +09:00
eastargh f091b9ab19 그리드 칼럼 사이즈 조정
eapim-admin CI / build (push) Has been cancelled
2026-08-03 11:39:28 +09:00
eastargh f47480674d UMS 설정
eapim-admin CI / build (push) Has been cancelled
2026-08-03 10:43:17 +09:00
Rinjae cd20aeee2a Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-07-31 17:31:55 +09:00
Rinjae 0365f4342e "SBOM 생성 및 변환 추가 - CycloneDX -> Excel(xlsx) 지원 - Jenkins 단계에 SBOM 빌드 및 아티팩트 보관 연결" 2026-07-31 17:31:46 +09:00
eastargh 0576fd4c6e UMS 발송 설정 변경
eapim-admin CI / build (push) Has been cancelled
2026-07-31 17:12:26 +09:00
eastargh 8215b596fe 레이아웃 저장시 numeric칼럼 숫자만 입력하도록 validation & focus 이동
eapim-admin CI / build (push) Has been cancelled
2026-07-31 13:58:32 +09:00
eastargh fc6b96d7cd 사이트맵 권한 있는 메뉴만 나오도록
eapim-admin CI / build (push) Has been cancelled
2026-07-31 13:27:14 +09:00
eastargh 5fe247df65 Merge branch 'master' of https://git.eactive.synology.me:8090/eapim/djbank/eapim-admin.git
eapim-admin CI / build (push) Has been cancelled
2026-07-31 10:24:41 +09:00
eastargh d11f7d9f38 client id 없는 거래 제휴사명 표기 2026-07-31 10:24:33 +09:00
Rinjae 889fd1f9f5 Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-07-30 19:41:14 +09:00
Rinjae c0844e8d36 Swing 알림 체계 도입 - 기존 Q&A 관리자 알림 제거 - Swing Notifier 구현 2026-07-30 19:35:54 +09:00
Rinjae db38b8f6e9 Swing 알림 체계 도입 - 기존 Q&A 관리자 알림 제거 - Swing Notifier 구현 2026-07-30 19:33:11 +09:00
Rinjae 8abe0a8585 GW 클라이언트 해지 처리 로직 추가 - BLOCK/DELETE 처리방식 지원
- APP 승인 시 GW 처리방식 설정 기능 추가 및 상세 표시
- DELETE 요청 처리 중 클라이언트 상태 동기화 로직 개선
- JSP에 GW 처리방식 선택 UI 및 승인 결과 표시 추가
2026-07-30 16:23:09 +09:00
eastargh ca8d46c7b8 사용자관리 최종로그인일시 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-30 14:58:18 +09:00
eastargh 628ab01c13 그리드 정렬 버그 수정
eapim-admin CI / build (push) Has been cancelled
2026-07-30 13:53:47 +09:00
eastargh f10f5ce08e 형상관리 연동시 API 스펙추가
eapim-admin CI / build (push) Has been cancelled
2026-07-30 11:44:19 +09:00
Rinjae 90ba584e4b API 스펙 관리 문구 및 로직 개선
eapim-admin CI / build (push) Has been cancelled
- 게이트웨이 관련 문구 수정 및 그룹 설정 안내 추가
- 비 로그인 사용자 공개 관련 문구 수정
- description 필드 저장/복원 로직 추가
2026-07-30 11:23:38 +09:00
Rinjae e59f64653f Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-07-29 19:24:38 +09:00
Rinjae 2422c7f01e OpenAPI 스펙 내보내기(UI/DL) 기능 추가
- 내보내기 활성화 여부 설정 키 추가(EXPORT_ENABLED_KEY)
- JSP/JS: 내보내기 버튼 및 다운로드 로직 연동
- YAML/JSON 포맷 저장 기능 구현
2026-07-29 19:24:31 +09:00
eastargh 29025d5f47 ..
eapim-admin CI / build (push) Has been cancelled
2026-07-29 15:21:04 +09:00
eastargh e91b12f2d7 IIM연동시 반복참조필드에 .있으면 이후 값만 저장
eapim-admin CI / build (push) Has been cancelled
2026-07-29 15:04:54 +09:00
eastargh da90a3a002 AUDIT 로그 - 파라미터 검색조건 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-29 10:18:44 +09:00
eastargh a4614ca7fd 로그인시 비밀번호 n회 실패시 계정 잠금처리 2026-07-29 10:14:50 +09:00
Rinjae 2d414dc31b - 삭제된 사용자/법인 포함 승인 요청 삭제 기능 추가
eapim-admin CI / build (push) Has been cancelled
- 삭제 버튼 관련 JSP/JS 수정 및 승인 상태별 버튼 표시 개선
- 초기 패스워드 설정 접미사 적용 및 강제 변경 유도 로직 추가
2026-07-28 15:37:58 +09:00
Rinjae 775d69eb53 - 셀 클릭 시 포커스 이동을 위한 clickedCol 변수 추가
eapim-admin CI / build (push) Has been cancelled
- onCellSelect 이벤트에 클릭한 컬럼 인덱스 저장 로직 구현
- editRow 호출 시 focusField로 클릭한 컬럼 지정
2026-07-28 12:40:41 +09:00
Rinjae 8f4162569d - 초기 패스워드 접미사 설정 로직 추가 및 관련 JSP 수정
eapim-admin CI / build (push) Has been cancelled
- API 허용 IP 리스트 변수명 변경(dapiAllowIpList → apiAllowIpList)
- 초기 패스워드 강제 변경 유도 로직 추가(비밀번호 변경일 초기화)
2026-07-27 15:05:51 +09:00
Rinjae adc5371df8 Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-07-24 17:22:16 +09:00
Rinjae e7297458f6 강제 반려 기능 추가
- 삭제된 승인자 있는 승인 요청 강제 반려 로직 추가
- JSP/JS: 강제 반려 버튼 추가 및 승인 상태별 표시 로직 구현
2026-07-24 17:22:10 +09:00
eastargh c10bfe8326 프라퍼티 수정시 서버 동기화 (자신 포함으로 변경)
eapim-admin CI / build (push) Has been cancelled
2026-07-24 12:57:00 +09:00
eastargh c224f80cdf 개발자포탈 정보 반영 주석처리
eapim-admin CI / build (push) Has been cancelled
2026-07-24 10:35:44 +09:00
eastargh 6c70d9611b 대시보드 상세통계 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-24 09:32:39 +09:00
Rinjae 26679cf649 - 테스트베드 프록시 설정 키 및 기능 추가(DjbApiSpecController)
eapim-admin CI / build (push) Has been cancelled
- 인증 스킴 라벨 판별 로직(schemeAuthLabel) 및 프록시 사용 여부 표시 로직 추가
- JSP/JS: 프록시 여부 관련 UI 및 데이터 연계 처리
2026-07-23 20:26:11 +09:00
Rinjae 406dd34e28 - Mock Server URL 처리 개선: 경로 분리(splitFullUrl) 로직 구현
eapim-admin CI / build (push) Has been cancelled
- 응답유형별 호출 주소 처리 로직 보완(GW/Mock 분리 대응)
- Mock 응답 시 전체 URL 유지 및 Swagger 스펙 반영 logic 수정
2026-07-23 19:30:39 +09:00
Rinjae 59dd28c23b - JSON 레이아웃: ARRAY 타입 스칼라/객체 배열 처리 로직 추가
eapim-admin CI / build (push) Has been cancelled
- JS: 배열 내부 객체(children) 및 타입(Render, Save) 동기화 로직 개선
- 스칼라 배열 예제 추가 및 Swagger 타입 매핑 로직 구현
2026-07-23 19:13:16 +09:00
Rinjae cd93e34840 weblogic.xml 수정
eapim-admin CI / build (push) Has been cancelled
- session-descriptor의 cookie-name 값 변경
2026-07-23 11:06:09 +09:00
eastargh 2fddffe29d IIM 연동시 로그 저장
eapim-admin CI / build (push) Has been cancelled
2026-07-23 10:41:51 +09:00
eastargh 7a73ef370f api통계 집계 쿼리 수정
eapim-admin CI / build (push) Has been cancelled
2026-07-22 16:29:28 +09:00
eastargh c9be6069ae iim연동시 기본값 추가 & 서버연동 제거
eapim-admin CI / build (push) Has been cancelled
2026-07-22 15:52:36 +09:00
Rinjae a13c8f154f Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-07-22 14:39:05 +09:00
Rinjae 8c98297634 - JSP: OpenAPI Spec 버튼 상태 표시 및 설명 레이어 추가
- Controller: OpenAPI Spec 상태 조회 API(specStatus) 구현
- Service: API 그룹명 기본값 적용 메서드 추가 및 그룹 설명 UI 구성
2026-07-22 14:38:58 +09:00
eastargh f36bfcc642 이전 1시간 데이타만 집계하도록 변경
eapim-admin CI / build (push) Has been cancelled
2026-07-22 11:15:29 +09:00
eastargh 2833944fe4 평균응답시간 오류 수정
eapim-admin CI / build (push) Has been cancelled
2026-07-22 09:44:06 +09:00
eastargh 18110641d6 IIM 레이아웃 연동 djb 커스텀
eapim-admin CI / build (push) Has been cancelled
2026-07-22 09:17:27 +09:00
eastargh 316f787276 API상태변환 수정
eapim-admin CI / build (push) Has been cancelled
2026-07-21 11:30:01 +09:00
eastargh c3c9f7a2da IIM 연동시 마스킹코드 적용
eapim-admin CI / build (push) Has been cancelled
2026-07-20 16:19:12 +09:00
eastargh 95e31befbb Djb 커스텀 원복
eapim-admin CI / build (push) Has been cancelled
2026-07-20 14:47:59 +09:00
eastargh f1f7576b9d 인터페이스 목록에 상태칼럼 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-20 11:46:07 +09:00
eastargh 545e09891b 어뎁터 없을때 상세조회 되도록 변경
eapim-admin CI / build (push) Has been cancelled
2026-07-20 10:53:33 +09:00
eastargh 70c2ac8376 서버 등록시 중복체크
eapim-admin CI / build (push) Has been cancelled
2026-07-16 14:52:36 +09:00
eastargh 21c493cb0e 통계 화면 레이아웃 조정
eapim-admin CI / build (push) Has been cancelled
2026-07-16 11:11:01 +09:00
eastargh b9c6a0da06 파이차트 글자 겹쳐보이는 버그 수정 (legend 오른쪽으로 이동)
eapim-admin CI / build (push) Has been cancelled
2026-07-16 10:14:37 +09:00
eastargh a0725b57a6 sms->kakao로 통일
eapim-admin CI / build (push) Has been cancelled
2026-07-16 09:27:37 +09:00
eastargh 50bfcacf27 전문레이아웃 변환 매핑 - api선택 팝업 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-15 16:52:34 +09:00
eastargh f400f0322f 유량제어 이력 검색조건 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-14 15:05:57 +09:00
eastargh d0cd8118f3 유량제어관련 메뉴 - 사용여부 검색조건 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-14 11:30:55 +09:00
Rinjae 07e65c055b - JSP: OpenAPI Spec 버튼 상태 수정 - NEW → DETAIL로 변경
eapim-admin CI / build (push) Has been cancelled
- Service: BIGDECIMAL 매핑 추가 - 반환 유형 'number'
- Monitoring: Properties 초기화 및 null 체크 로직 추가
- JS: 샘플 응답 상태 시 안내 메시지 추가
2026-07-14 11:11:24 +09:00
eastargh af8a9cca51 프라퍼티 정리
eapim-admin CI / build (push) Has been cancelled
2026-07-13 17:47:35 +09:00
eastargh 39a09d0f5d 유량제어 url 특수문자 escape 2026-07-13 17:47:08 +09:00
Rinjae 5b940717c4 Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-07-13 15:30:13 +09:00
Rinjae 8ccf52fd66 - JSP: OpenAPI Spec 모달 처리 개선 - 동적 크기 조절 및 스타일 추가
- Service: GET/DELETE 요청 바디 항목→쿼리 파라미터 매핑 로직 추가
- JS: 요청 정보 진입 시 탭 자동 전환 로직(firstReqTabWithData) 구현
2026-07-13 15:30:08 +09:00
eastargh dedc58a07f 유량제어 추가임계치가 null or 단위선택 않하면 0으로 설정
eapim-admin CI / build (push) Has been cancelled
2026-07-13 15:24:30 +09:00
Rinjae 4530af9dec Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
# Conflicts:
#	build.gradle
2026-07-13 14:28:49 +09:00
Rinjae cbf3066a56 API 그룹 팝업 JSP 파일 추가 - 예제 코드 및 UI 구조 작성
Summernote 에디터 스타일 CSS 추가 - 텍스트 및 컴포넌트 스타일 정의
OpenAPI POC JS 파일 추가 - API 스펙 빌더 및 스텝별 렌더 구현
2026-07-13 14:25:09 +09:00
eastargh 160e28444a eapim-admin-djb 제거
eapim-admin CI / build (push) Has been cancelled
2026-07-13 13:20:22 +09:00
Rinjae d7dcf9a81c Q&A 문의 공개범위 기능 추가
eapim-admin CI / build (push) Has been cancelled
- 공개범위 설정 기능 개발(ALL/ORG/PRIVATE) - 게시물/댓글 정교화
- 요청 및 DB 처리 확장 - UI, DTO, Service 계층 업데이트
- 감사 로그 및 조회수 처리 로직 추가
2026-07-13 11:32:34 +09:00
Rinjae acfe6b2d03 weblogic.xml 업데이트
eapim-admin CI / build (push) Has been cancelled
- XML 태그 단순화 및 불필요한 속성 제거
- session-descriptor에 cookie-name 추가
2026-07-10 17:48:34 +09:00
eastargh 481e82fec7 Merge branch 'master' of https://git.eactive.synology.me:8090/eapim/djbank/eapim-admin.git
eapim-admin CI / build (push) Has been cancelled
2026-07-10 11:37:43 +09:00
eastargh 1b382f5859 ums 설정 추가 2026-07-10 11:37:33 +09:00
Rinjae 608cb4188e "Jenkinsfile 개선:
eapim-admin CI / build (push) Has been cancelled
- Tomcat 배포 구조 → WebLogic 기반으로 변경
- stage별 agent 명시 및 불필요한 환경변수 제거
- Webhook 알림 로직 추가"
2026-07-09 16:20:54 +09:00
eastargh 534947637d qna 상세 회원이름 표시
eapim-admin CI / build (push) Has been cancelled
2026-07-09 14:55:07 +09:00
eastargh 153c68c6ab 사이트맵 위치 변경
eapim-admin CI / build (push) Has been cancelled
2026-07-09 11:47:23 +09:00
eastargh 80ef41f4fc QnA 문의검토중 상태 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-09 09:26:24 +09:00
Rinjae 8aebabe005 Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-07-08 18:31:45 +09:00
Rinjae 6d33b69bed 인증 서버 클라이언트 차단 API 추가:
- `ClientBlockApiController` 신규 생성 및 차단 로직 구현
- `ClientManService`에 클라이언트 차단 메서드 추가
- 승인 요청 삭제 로직 개발 환경 제한 문구 수정
2026-07-08 18:31:39 +09:00
eastargh 49ebdce4c3 전문레이아웃 그리드 항목명 순서 변경(김태호이사 요청)
eapim-admin CI / build (push) Has been cancelled
2026-07-08 11:13:43 +09:00
eastargh 979e43e382 - 포함된 인터페이스명 복제 버그 수정
eapim-admin CI / build (push) Has been cancelled
2026-07-08 10:37:03 +09:00
eastargh 53b075967c 스타일시트 정리
eapim-admin CI / build (push) Has been cancelled
2026-07-07 16:53:22 +09:00
eastargh 724117860b 탑메뉴 오버레이 높이 조정
eapim-admin CI / build (push) Has been cancelled
2026-07-07 14:47:02 +09:00
eastargh 0ee52392aa 조회기간 기본설정 변경
eapim-admin CI / build (push) Has been cancelled
2026-07-07 14:09:58 +09:00
eastargh f0af06da88 사이트맵
eapim-admin CI / build (push) Has been cancelled
2026-07-07 13:38:44 +09:00
eastargh b16d03517a 사이트맵 신규 개발
eapim-admin CI / build (push) Has been cancelled
2026-07-07 13:19:33 +09:00
Rinjae edf024f0a3 Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-07-07 10:35:26 +09:00
Rinjae a1a1f27498 승인 관리: 승인 상태별 행 배경색 강조 로직 추가 2026-07-07 10:34:41 +09:00
eastargh c62f395983 Merge branch 'master' of https://git.eactive.synology.me:8090/eapim/djbank/eapim-admin.git
eapim-admin CI / build (push) Has been cancelled
2026-07-06 16:34:55 +09:00
eastargh c3955cefd8 Scope관리 Api Reload 버그 수정 2026-07-06 16:26:40 +09:00
Rinjae 5c378eac0d Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-07-06 15:33:39 +09:00
Rinjae e51cdbc42a 감사로그 및 삭제 프로세스 개선:
- 감사 사유 입력 기능 추가 및 UI 수정
- 사용/법인 삭제 시 '완전삭제' 여부 확인 및 로직 적용
- 감사로그에 사유(message) 저장 필드 추가
2026-07-06 15:33:32 +09:00
eastargh 142d402bad 레이아웃관리 - Array 아이템유형 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-06 11:25:16 +09:00
eastargh f23cb02b1c 웹훅 발송 내역 2026-07-03 17:15:57 +09:00
eastargh eca120812c 관리자포탈 비밀번호 규칙 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-02 16:34:45 +09:00
eastargh 71b0fc7009 오류변환만 있는 경우, 전문변환 ON으로 설정 2026-07-02 16:34:20 +09:00
eastargh 7b12ef2fb5 웹훅 발송 내역 신규
eapim-admin CI / build (push) Has been cancelled
2026-07-02 14:46:02 +09:00
eastargh 292e27e66e 에러메세지 수정
eapim-admin CI / build (push) Has been cancelled
2026-07-02 13:28:31 +09:00
eastargh 61d710dd8c 초기비밀번호로 로그인시 비밀번호변경 모달 오픈
eapim-admin CI / build (push) Has been cancelled
2026-07-01 14:23:55 +09:00
eastargh 8494758fcd 신규 비밀번호 및 초기화시 사번+@! 로 설정
eapim-admin CI / build (push) Has been cancelled
2026-07-01 10:27:18 +09:00
eastargh f2b25d2775 API명 검색조건 sql에 추가
eapim-admin CI / build (push) Has been cancelled
2026-07-01 10:06:56 +09:00
169 changed files with 7839 additions and 1682 deletions
Vendored
+81 -72
View File
@@ -1,31 +1,31 @@
pipeline { pipeline {
agent { label 'djb-vm' } agent none
options { options {
timestamps() timestamps()
disableConcurrentBuilds() disableConcurrentBuilds()
skipDefaultCheckout() // stage별 agent의 자동 SCM checkout 방지 (Deploy 노드는 git 접근 불필요, unstash로만 WAR 수신)
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) 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 { environment {
JAVA_HOME = '/apps/opts/jdk8' JAVA_HOME = '/apps/opts/jdk8'
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
GRADLE_HOME = '/apps/opts/gradle-8.7' GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home' GRADLE_USER_HOME = '/apps/opts/gradle-home'
NODE_HOME = '/apps/opts/node-v24' 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}" PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
CATALINA_BASE = '/prod/eapim/adminportal'
CATALINA_HOME = '/prod/eapim/apache-tomcat-9.0.116'
CATALINA_PID = '/prod/eapim/adminportal/temp/catalina.pid'
DEPLOY_HTTP_PORT = '39120'
} }
stages { stages {
stage('Checkout') { stage('Checkout') {
steps { steps { checkout scm }
checkout scm
}
} }
stage('Checkout dependencies') { stage('Checkout dependencies') {
@@ -77,104 +77,113 @@ pipeline {
} }
stage('Build WAR') { stage('Build WAR') {
steps { steps { sh 'gradle clean build -x test --no-daemon -Pprofile=weblogic' }
sh 'gradle clean build -x test --no-daemon'
}
post { post {
success { success {
sh ''' sh '''
set -eu set -eu
cd build/libs cd build/libs
sha1sum eapim-admin.war > eapim-admin.war.sha1
sha256sum eapim-admin.war > eapim-admin.war.sha256 sha256sum eapim-admin.war > eapim-admin.war.sha256
md5sum eapim-admin.war > eapim-admin.war.md5
''' '''
archiveArtifacts artifacts: 'build/libs/eapim-admin.war,build/libs/eapim-admin.war.sha1,build/libs/eapim-admin.war.sha256,build/libs/eapim-admin.war.md5', fingerprint: true archiveArtifacts artifacts: 'build/libs/eapim-admin.war,build/libs/eapim-admin.war.sha256', fingerprint: true
stash name: 'war', includes: 'build/libs/eapim-admin.war'
} }
} }
} }
stage('Stop Tomcat') { // SBOM(CycloneDX) -> xlsx. 산출물 eapim-admin-sbom.xlsx 를 아티팩트로 보관.
// 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
stage('SBOM') {
steps { steps {
sh ''' catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
set +e sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
systemctl --user stop eapim-admin 2>/dev/null }
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
}
}
if [ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null; then stage('Deploy (weblogic)') {
JAVA_HOME="$JAVA_HOME_TOMCAT" "$CATALINA_HOME/bin/shutdown.sh" 30 -force || true agent { label 'weblogic' }
for i in $(seq 1 30); do environment {
[ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null || break JENKINS_NODE_COOKIE = 'dontKillMe' // background weblogic를 빌드 종료 시 죽이지 않게
sleep 1 WL_HOME = '/app/eapim/adminportal'
done WL_DEPLOY_DIR = '/app/eapim/adminportal'
fi WL_WAR_NAME = 'eapim-admin.war'
rm -f "$CATALINA_PID" WL_HTTP_PORT = '39120'
WL_NOHUP = '/logs/weblogic/domains/eapimDomain/nohup.emsSvr11.out'
}
stages {
stage('Stop WebLogic') {
steps {
sh '"$WL_HOME/stopEms11.sh"' // 동기: 완전 종료까지 블록, 미기동 시에도 안전
}
}
stage('Deploy WAR') {
steps {
unstash 'war'
sh '''
set -eu
cp build/libs/eapim-admin.war "$WL_DEPLOY_DIR/$WL_WAR_NAME"
ls -la "$WL_DEPLOY_DIR"
''' '''
} }
} }
stage('Start WebLogic and readiness') {
stage('Deploy monitoring.war') {
steps { steps {
sh ''' sh '''
set -eu set -eu
DEPLOY_DIR="$CATALINA_BASE/webapps" "$WL_HOME/startEms11.sh" # nohup & 로 백그라운드 기동, 즉시 리턴
rm -rf "$DEPLOY_DIR/monitoring" "$DEPLOY_DIR/monitoring.war"
cp build/libs/eapim-admin.war "$DEPLOY_DIR/monitoring.war"
ls -la "$DEPLOY_DIR"
'''
}
}
stage('Start Tomcat and readiness') {
steps {
sh '''
set -eu
LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
BEFORE=0
[ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
systemctl --user start eapim-admin
PROBE_URL="http://localhost:$DEPLOY_HTTP_PORT/monitoring/loginForm.do"
DEADLINE=$(($(date +%s) + 300)) DEADLINE=$(($(date +%s) + 300))
STATUS=000 STATUS=000
while [ "$(date +%s)" -lt "$DEADLINE" ]; do while [ "$(date +%s)" -lt "$DEADLINE" ]; do
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \ STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
"$PROBE_URL" 2>/dev/null || echo "000") "http://localhost:$WL_HTTP_PORT/monitoring/loginForm.do" 2>/dev/null || echo "000")
case "$STATUS" in case "$STATUS" in
200|302|401) 200|302|401) echo "Readiness OK (/monitoring/loginForm.do HTTP $STATUS)"; break ;;
echo "Readiness OK ($PROBE_URL HTTP $STATUS)"
break
;;
esac esac
sleep 3
if [ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | grep -qE "Application listener .* Exception|SEVERE.*startup.Catalina"; then
echo "Startup error detected in log"
tail -c +$((BEFORE+1)) "$LOG" | tail -80
exit 1
fi
sleep 2
done done
case "$STATUS" in case "$STATUS" in
200|302|401) ;; 200|302|401) ;;
*) *)
echo "Readiness probe failed within 300s, last HTTP status: $STATUS" echo "Readiness failed within 300s, last HTTP=$STATUS"
echo "--- last 300 lines of catalina log ---" [ -f "$WL_NOHUP" ] && tail -120 "$WL_NOHUP"
[ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | tail -300
echo "--- systemd unit status ---"
systemctl --user status eapim-admin --no-pager || true
echo "--- listening ports ---"
ss -tlnp 2>/dev/null | grep -E ":$DEPLOY_HTTP_PORT\\b" || echo "(port $DEPLOY_HTTP_PORT not listening)"
exit 1 exit 1
;; ;;
esac esac
echo "--- key boot log lines ---"
tail -c +$((BEFORE+1)) "$LOG" | grep -E "Server startup|Deployment of web" | head -10 || true
''' '''
} }
} }
} }
}
}
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"
'''
}
}
}
} }
+2
View File
@@ -16,3 +16,5 @@ PortalPartnershipManController_피드백/개선요청관리_APIGW_UNMASK,DELETE
PortalPropertyController_포탈 프로퍼티 관리_APIGW_UPDATE PortalPropertyController_포탈 프로퍼티 관리_APIGW_UPDATE
PortalUserTermsManController_약관동의 이력_APIGW_UNMASK PortalUserTermsManController_약관동의 이력_APIGW_UNMASK
MessageRequestManController_메세지발송내역_APIGW_UPDATE_STATUS MessageRequestManController_메세지발송내역_APIGW_UPDATE_STATUS
PortalInquiryManController_Q&A문의관리_APIGW_VISIBILITY
PortalMenuManController_포탈메뉴관리_APIGW_INSERT,UPDATE,DELETE,TRANSACTION_PLACEMENT,INITIALIZE,TRANSACTION_RELOAD
-43
View File
@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
targetNamespace="http://xmlns.oracle.com/weblogic/weblogic-web-app"
xmlns="http://xmlns.oracle.com/weblogic/weblogic-web-app"
elementFormDefault="qualified">
<xs:element name="weblogic-web-app">
<xs:complexType>
<xs:all>
<xs:element name="context-root" type="xs:string" minOccurs="0"/>
<xs:element name="session-descriptor" minOccurs="0">
<xs:complexType>
<xs:all>
<xs:element name="timeout-secs" type="xs:integer" minOccurs="0"/>
<xs:element name="persistent-store-type" type="xs:string" minOccurs="0"/>
</xs:all>
</xs:complexType>
</xs:element>
<xs:element name="container-descriptor" minOccurs="0">
<xs:complexType>
<xs:all>
<xs:element name="prefer-application-packages" minOccurs="0">
<xs:complexType>
<xs:sequence>
<xs:element name="package-name" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="prefer-application-resources" minOccurs="0">
<xs:complexType>
<xs:sequence>
<xs:element name="resource-name" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:all>
</xs:complexType>
</xs:element>
</xs:all>
</xs:complexType>
</xs:element>
</xs:schema>
+2 -4
View File
@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<weblogic-web-app <weblogic-web-app xmlns="http://xmlns.oracle.com/weblogic/weblogic-web-app">
xmlns="http://xmlns.oracle.com/weblogic/weblogic-web-app"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.oracle.com/weblogic/weblogic-web-app weblogic-web-app.xsd">
<context-root>monitoring</context-root> <context-root>monitoring</context-root>
<session-descriptor> <session-descriptor>
<timeout-secs>1800</timeout-secs> <timeout-secs>1800</timeout-secs>
<cookie-name>JSESSIONID_EMS</cookie-name>
<persistent-store-type>replicated_if_clustered</persistent-store-type> <persistent-store-type>replicated_if_clustered</persistent-store-type>
</session-descriptor> </session-descriptor>
+1
View File
@@ -1,6 +1,7 @@
@charset "utf-8"; @charset "utf-8";
.gnb .depth1 > li:hover > a{color:#008cc5;} /* theme */ .gnb .depth1 > li:hover > a{color:#008cc5;} /* theme */
.gnb .sitemap-link:hover{color:#008cc5;} /* theme */
.gnb .depth1 > li > .red_box{background:#008cc5;}/* theme */ .gnb .depth1 > li > .red_box{background:#008cc5;}/* theme */
.gnb .depth2 > li > a:hover{color:#008cc5;}/* theme */ .gnb .depth2 > li > a:hover{color:#008cc5;}/* theme */
+1
View File
@@ -1,6 +1,7 @@
@charset "utf-8"; @charset "utf-8";
.gnb .depth1 > li:hover > a{color:#16a085;} /* theme */ .gnb .depth1 > li:hover > a{color:#16a085;} /* theme */
.gnb .sitemap-link:hover{color:#16a085;} /* theme */
.gnb .depth1 > li > .red_box{background:#16a085;}/* theme */ .gnb .depth1 > li > .red_box{background:#16a085;}/* theme */
.gnb .depth2 > li > a:hover{color:#16a085;}/* theme */ .gnb .depth2 > li > a:hover{color:#16a085;}/* theme */
+1
View File
@@ -1,6 +1,7 @@
@charset "utf-8"; @charset "utf-8";
.gnb .depth1 > li:hover > a{color:#e74c3c;} /* theme */ .gnb .depth1 > li:hover > a{color:#e74c3c;} /* theme */
.gnb .sitemap-link:hover{color:#e74c3c;} /* theme */
.gnb .depth1 > li > .red_box{background:#e74c3c;}/* theme */ .gnb .depth1 > li > .red_box{background:#e74c3c;}/* theme */
.gnb .depth2 > li > a:hover{color:#e74c3c;}/* theme */ .gnb .depth2 > li > a:hover{color:#e74c3c;}/* theme */
+1
View File
@@ -1,6 +1,7 @@
@charset "utf-8"; @charset "utf-8";
.gnb .depth1 > li:hover > a{color:#ff80c0;} /* theme */ .gnb .depth1 > li:hover > a{color:#ff80c0;} /* theme */
.gnb .sitemap-link:hover{color:#ff80c0;} /* theme */
.gnb .depth1 > li > .red_box{background:#ff80c0;}/* theme */ .gnb .depth1 > li > .red_box{background:#ff80c0;}/* theme */
.gnb .depth2 > li > a:hover{color:#ff80c0;}/* theme */ .gnb .depth2 > li > a:hover{color:#ff80c0;}/* theme */
+1
View File
@@ -1,6 +1,7 @@
@charset "utf-8"; @charset "utf-8";
.gnb .depth1 > li:hover > a{color:#be8200;} /* theme */ .gnb .depth1 > li:hover > a{color:#be8200;} /* theme */
.gnb .sitemap-link:hover{color:#be8200;} /* theme */
.gnb .depth1 > li > .red_box{background:#be8200;}/* theme */ .gnb .depth1 > li > .red_box{background:#be8200;}/* theme */
.gnb .depth2 > li > a:hover{color:#be8200;}/* theme */ .gnb .depth2 > li > a:hover{color:#be8200;}/* theme */
+9 -9
View File
@@ -62,8 +62,8 @@ header.sub{position:relative; padding-top:80px;}
100% { color:rgba(255,96,78,0); } 100% { color:rgba(255,96,78,0); }
} */ } */
.gnb_bg{position:absolute; top:80px; left:0; display:none; width:100%; height:382px; box-sizing:border-box; background:white; box-shadow: 0px 5px 5px 0px rgba(0,0,0,0.2);} .gnb_bg{position:absolute; top:80px; left:0; display:none; width:100%; height:282px; box-sizing:border-box; background:white; box-shadow: 0px 5px 5px 0px rgba(0,0,0,0.2);}
.gnb{position:absolute; top:0px; left:230px; display:inline-block; height:80px; overflow-y:hidden;} .gnb{position:absolute; top:0px; left:180px; display:inline-block; height:80px; overflow-y:hidden;}
.gnb:hover{height:auto; overflow-y:visible;} .gnb:hover{height:auto; overflow-y:visible;}
.gnb:hover + .gnb_bg{display:block;} .gnb:hover + .gnb_bg{display:block;}
.gnb h1{float:left; display:block; width:auto; height:100%; line-height:80px;} .gnb h1{float:left; display:block; width:auto; height:100%; line-height:80px;}
@@ -71,15 +71,15 @@ header.sub{position:relative; padding-top:80px;}
.gnb .depth1 > li{position:relative;} .gnb .depth1 > li{position:relative;}
.gnb .depth1 > li > a{position:relative; display:block; box-sizing:border-box; color:#000; font-size:14px; width:160px; height:80px; text-align:center; line-height:80px; letter-spacing:-0.5px; opacity:0.7; transition:all 0.3s;} .gnb .depth1 > li > a{position:relative; display:block; box-sizing:border-box; color:#000; font-size:14px; width:160px; height:80px; text-align:center; line-height:80px; letter-spacing:-0.5px; opacity:0.7; transition:all 0.3s;}
.gnb .depth1 > li > a:after{content:''; position:absolute; top:34px; right:0; display:inline-block; width:1px; height:13px; background:#ddd;} .gnb .depth1 > li > a:after{content:''; position:absolute; top:34px; right:0; display:inline-block; width:1px; height:13px; }
.gnb .depth1 > li:first-child > a:before{content:''; position:absolute; top:34px; left:0; display:inline-block; width:1px; height:13px; background:#ddd;} .gnb .depth1 > li:first-child > a:before{content:''; position:absolute; top:34px; left:0; display:inline-block; width:1px; height:13px; }
/* /*
.gnb .depth1 > li:hover .red_box{transform:scaleX(1);} .gnb .depth1 > li:hover .red_box{transform:scaleX(1);}
.gnb .depth1 > li:hover > a{opacity:1;} .gnb .depth1 > li:hover > a{opacity:1;}
.gnb .depth1 > li > .red_box{display:block; position:absolute; width:100%; height:2px; top:78px; box-sizing:border-box; background:#ed1c24; transform-origin:center center; transform:scaleX(0); transition:all 0.3s;} .gnb .depth1 > li > .red_box{display:block; position:absolute; width:100%; height:2px; top:78px; box-sizing:border-box; background:#ed1c24; transform-origin:center center; transform:scaleX(0); transition:all 0.3s;}
*/ */
.gnb .depth2{position:absolute; left:0; top:80px; width:160px; height:410px; box-sizing:border-box; padding:20px 0; border-right:1px solid #eee; text-align:center; z-index:9;} .gnb .depth2{position:absolute; left:0; top:80px; width:160px; height:280px; box-sizing:border-box; padding:20px 0; border-right:1px solid #eee; text-align:center; z-index:9;}
.gnb .depth2.first{border-left:1px solid #eee;} .gnb .depth1 > li:first-child .depth2{border-left:1px solid #eee;}
.gnb .depth2 > li > a{display:block; color:#000; font-size:13px; line-height:30px;} .gnb .depth2 > li > a{display:block; color:#000; font-size:13px; line-height:30px;}
.gnb .depth2 > li > a:hover{color:#ff0000;} .gnb .depth2 > li > a:hover{color:#ff0000;}
@@ -94,9 +94,9 @@ header.sub{position:relative; padding-top:80px;}
.left_box .depth3 > li:hover > a, .left_box .depth3 > li.on > a{color:#ed1c24;} .left_box .depth3 > li:hover > a, .left_box .depth3 > li.on > a{color:#ed1c24;}
.content_top{position:fixed; top:0; left:0; padding:0 30px; width:100%; height:50px; line-height:50px; background:#eee; border-top:1px solid #ddd; border-left:1px solid #ddd; z-index:100;} .content_top{position:fixed; top:0; left:0; padding:0 30px; width:100%; height:50px; line-height:50px; background:#eee; border-top:1px solid #ddd; border-left:1px solid #ddd; z-index:100;}
.content_top .path{} .content_top .path{margin:0; padding:0;}
.content_top .path > li{display:inline-block;} .content_top .path > li{display:inline-block; margin:0; padding:0;}
.content_top .path > li a{display:block; height:50px; line-height:50px; padding-left:10px; font-size:12px; color:#999;} .content_top .path > li a{display:block; height:50px; line-height:50px; padding-left:10px; font-size:12px; color:#999; text-decoration:none;}
.content_top .path > li a:hover{color:#666;} .content_top .path > li a:hover{color:#666;}
.content_top .path > li a:before{content:'>'; margin-right:10px;} .content_top .path > li a:before{content:'>'; margin-right:10px;}
.content_top .path > li:first-child a:before{content:'';} .content_top .path > li:first-child a:before{content:'';}
+128 -40
View File
@@ -25,6 +25,10 @@
components: [], // 재사용 스키마(POC: 빈 상태) components: [], // 재사용 스키마(POC: 빈 상태)
}; };
// GW authtype(oauth/api_key 등, 서버 DETAIL_SPEC 응답). 인증 badge 라벨 판별용.
// (enricher 는 oauth·api_key 를 모두 apiKey-in-header 로 모델링하므로 scheme.type 만으로 구분 불가)
var djbAuthType = '';
// ============================================================ // ============================================================
// 2. 유틸 // 2. 유틸
// ============================================================ // ============================================================
@@ -189,15 +193,23 @@
// 경로(op.path.value)에는 어댑터경로가 이미 baking 됨(백엔드 applyAutoRules). 서버에는 응답유형별 호스트만 붙인다. // 경로(op.path.value)에는 어댑터경로가 이미 baking 됨(백엔드 applyAutoRules). 서버에는 응답유형별 호스트만 붙인다.
// - gw : djb.gateway.base-url (PortalProperty) → [gw호스트 + 어댑터경로 + 경로] // - gw : djb.gateway.base-url (PortalProperty) → [gw호스트 + 어댑터경로 + 경로]
// - mock : Mock Server URL → [mock호스트 + 어댑터경로 + 경로] // - mock : Mock Server URL(전체 URL) → [mock URL 그대로] (어댑터/오퍼레이션 경로 미부착)
// - 그 외 : 호스트 없음 → [어댑터경로 + 경로] // - 그 외 : 호스트 없음 → [어댑터경로 + 경로]
var _rt = (d.portal && d.portal.responseType) || 'sample'; var _rt = (d.portal && d.portal.responseType) || 'sample';
var _host; var _host;
if (_rt === 'gw') _host = (window.DJB_CTX && window.DJB_CTX.gwAddress) || 'http://127.0.0.1:39310'; var _pathKey = op.path.value;
else if (_rt === 'mock') _host = (d.portal && d.portal.mockUrl) || ''; if (_rt === 'gw') {
else _host = ''; _host = (window.DJB_CTX && window.DJB_CTX.gwAddress) || 'http://127.0.0.1:39310';
} else if (_rt === 'mock') {
// Mock 은 전체 호출 URL. origin=서버, pathname(+query)=경로로 분해해 경로가 뒤에 덧붙지 않도록 한다.
var _mk = splitFullUrl((d.portal && d.portal.mockUrl) || '');
_host = _mk.origin;
_pathKey = _mk.path;
} else {
_host = '';
}
if (_host) spec.servers = [{ url: _host }]; if (_host) spec.servers = [{ url: _host }];
spec.paths[op.path.value] = { [op.method.value.toLowerCase()]: operation }; spec.paths[_pathKey] = { [op.method.value.toLowerCase()]: operation };
// 보안 스킴 // 보안 스킴
d.securitySchemes.forEach(s => { d.securitySchemes.forEach(s => {
@@ -248,9 +260,11 @@
if (row.description && row.description.value) obj.properties[name].description = row.description.value; if (row.description && row.description.value) obj.properties[name].description = row.description.value;
if (row.name && row.name.locked) obj.properties[name]['x-djb-gw'] = true; if (row.name && row.name.locked) obj.properties[name]['x-djb-gw'] = true;
} else if (row.type.value === 'array') { } else if (row.type.value === 'array') {
const itemType = (row.itemsType && row.itemsType.value) || 'string';
obj.properties[name] = { obj.properties[name] = {
type: 'array', type: 'array',
items: { type: (row.itemsType && row.itemsType.value) || 'string' } // array<object>(GW GRID): 자식 행을 items.properties 로 직렬화. (누락 시 저장 왕복에서 자식 유실)
items: itemType === 'object' ? schemaArrayToObject(row.children || []) : { type: itemType }
}; };
if (row.description && row.description.value) obj.properties[name].description = row.description.value; if (row.description && row.description.value) obj.properties[name].description = row.description.value;
if (row.name && row.name.locked) obj.properties[name]['x-djb-gw'] = true; if (row.name && row.name.locked) obj.properties[name]['x-djb-gw'] = true;
@@ -301,7 +315,7 @@
function goStep(n) { function goStep(n) {
if (n < 1 || n > STEPS.length) return; if (n < 1 || n > STEPS.length) return;
state.step = n; state.step = n;
if (n === 2) state.reqInfoTab = 'body'; // 요청/응답 정보: 진입 시 항상 Body 먼저 if (n === 2) state.reqInfoTab = firstReqTabWithData(); // 요청 정보: 값 있는 탭(Header→Parameter→Body 순) 먼저
if (n === 3) state.resInfoTab = 'body'; if (n === 3) state.resInfoTab = 'body';
renderStepper(); renderStepper();
renderForm(); renderForm();
@@ -335,8 +349,9 @@
} }
function fieldRow(label, controlHtml, opts) { function fieldRow(label, controlHtml, opts) {
opts = opts || {}; opts = opts || {};
const rowSpacing = opts.bordered ? 'px-3 py-3' : 'mb-3';
return ` return `
<div class="grid grid-cols-12 items-start gap-3 mb-3"> <div class="grid grid-cols-12 items-start gap-3 ${rowSpacing}">
<label class="col-span-3 pt-1.5 text-sm text-slate-600 ${opts.required ? 'font-medium' : ''}"> <label class="col-span-3 pt-1.5 text-sm text-slate-600 ${opts.required ? 'font-medium' : ''}">
${escapeHtml(label)}${opts.required ? ' <span class="text-red-500">*</span>' : ''} ${escapeHtml(label)}${opts.required ? ' <span class="text-red-500">*</span>' : ''}
${opts.locked ? '<span class="ml-1 text-slate-400 text-xs">🔒</span>' : ''} ${opts.locked ? '<span class="ml-1 text-slate-400 text-xs">🔒</span>' : ''}
@@ -366,7 +381,7 @@
`<div class="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-slate-200 rounded"> `<div class="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-slate-200 rounded">
<span class="px-2.5 py-1 text-xs font-bold rounded border ${methodColor}">${escapeHtml(op.method.value)}</span> <span class="px-2.5 py-1 text-xs font-bold rounded border ${methodColor}">${escapeHtml(op.method.value)}</span>
<code class="text-sm font-mono text-slate-800">${escapeHtml(op.path.value)}</code> <code class="text-sm font-mono text-slate-800">${escapeHtml(op.path.value)}</code>
<span class="text-xs text-slate-400 ml-auto">🔒 게이트웨이에서 자동 동기화</span> <span class="text-xs text-slate-400 ml-auto">🔒 게이트웨이 설정</span>
</div>` + </div>` +
fieldRow('Operation ID', lockedInput((window.DJB_CTX && window.DJB_CTX.eaiSvcName) || op.operationId.value), { locked: true }) + fieldRow('Operation ID', lockedInput((window.DJB_CTX && window.DJB_CTX.eaiSvcName) || op.operationId.value), { locked: true }) +
fieldRow('API 타이틀', input(i.title.value, 'data-path="info.title.value"', { required: true }), { required: true }) + fieldRow('API 타이틀', input(i.title.value, 'data-path="info.title.value"', { required: true }), { required: true }) +
@@ -439,6 +454,19 @@
+ '</div>'; + '</div>';
} }
// 요청 정보 진입 시 먼저 보일 서브탭 = 값이 든 첫 탭(Header→Parameter→Body 순).
// 보통 GET 은 Parameter(query), POST 는 Body 에만 값이 있음. 전부 비면 Body 로.
function firstReqTabWithData() {
var params = (state.data && state.data.parameters) || [];
var hasHeader = params.some(function (p) { return p.in === 'header'; });
var hasParam = params.some(function (p) { return p.in === 'query'; });
var hasBody = !!(state.data && state.data.requestBody && state.data.requestBody.schema && state.data.requestBody.schema.length);
if (hasHeader) return 'header';
if (hasParam) return 'param';
if (hasBody) return 'body';
return 'body';
}
// Step 2 — 요청 정보 (Header / Parameter / Body / API 설명(HTML)) // Step 2 — 요청 정보 (Header / Parameter / Body / API 설명(HTML))
function renderReqInfo(root) { function renderReqInfo(root) {
const tab = state.reqInfoTab || 'body'; const tab = state.reqInfoTab || 'body';
@@ -659,6 +687,7 @@
const isLocked = row.name.locked; const isLocked = row.name.locked;
const isObject = row.type.value === 'object'; const isObject = row.type.value === 'object';
const isArray = row.type.value === 'array'; const isArray = row.type.value === 'array';
const isArrayOfObject = isArray && (row.itemsType && row.itemsType.value) === 'object';
const expandedKey = `${path}.__expanded`; const expandedKey = `${path}.__expanded`;
const isExpanded = state[expandedKey] !== false; const isExpanded = state[expandedKey] !== false;
@@ -668,7 +697,7 @@
<td class="py-2 px-2 text-center">${isLocked ? '<span class="text-slate-400 text-xs" title="게이트웨이 동기화">🔒</span>' : ''}</td> <td class="py-2 px-2 text-center">${isLocked ? '<span class="text-slate-400 text-xs" title="게이트웨이 동기화">🔒</span>' : ''}</td>
<td class="py-2 px-2"> <td class="py-2 px-2">
<div class="flex items-center gap-1" style="padding-left:${depth * 18}px;"> <div class="flex items-center gap-1" style="padding-left:${depth * 18}px;">
${isObject || isArray ${isObject || isArrayOfObject
? `<button data-toggle-expand="${path}" class="w-4 h-4 text-slate-400 hover:text-slate-700">${isExpanded ? '▾' : '▸'}</button>` ? `<button data-toggle-expand="${path}" class="w-4 h-4 text-slate-400 hover:text-slate-700">${isExpanded ? '▾' : '▸'}</button>`
: `<span class="w-4 inline-block"></span>`} : `<span class="w-4 inline-block"></span>`}
<span class="text-xs text-slate-400 mr-1">${isObject ? '{}' : isArray ? '[]' : '·'}</span> <span class="text-xs text-slate-400 mr-1">${isObject ? '{}' : isArray ? '[]' : '·'}</span>
@@ -699,7 +728,7 @@
isArray isArray
? `<div class="flex gap-1 items-center"> ? `<div class="flex gap-1 items-center">
<span class="text-xs text-slate-400">items:</span> <span class="text-xs text-slate-400">items:</span>
${selectInput((row.itemsType && row.itemsType.value) || 'string', ['string', 'integer', 'number', 'boolean'], `data-path="${path}.itemsType.value"`, { extra: 'text-xs' })} ${selectInput((row.itemsType && row.itemsType.value) || 'string', ['string', 'integer', 'number', 'boolean', 'object'], `data-path="${path}.itemsType.value" data-rerender="step3"`, { extra: 'text-xs' })}
</div>` </div>`
: input(row.example.value, `data-path="${path}.example.value"`, { placeholder: '예제값' })} : input(row.example.value, `data-path="${path}.example.value"`, { placeholder: '예제값' })}
</td> </td>
@@ -711,8 +740,8 @@
</tr> </tr>
`; `;
// children // children (object + array<object>(GRID) 공통)
if (isObject && isExpanded) { if ((isObject || isArrayOfObject) && isExpanded) {
if (row.children && row.children.length) { if (row.children && row.children.length) {
html += renderSchemaRows(row.children, `${path}.children`, depth + 1, seq); html += renderSchemaRows(row.children, `${path}.children`, depth + 1, seq);
} }
@@ -731,22 +760,44 @@
// ============================================================ // ============================================================
// 9. 인증 / 보안 (기본정보 Step 에 병합된 섹션) // 9. 인증 / 보안 (기본정보 Step 에 병합된 섹션)
// ============================================================ // ============================================================
// 테스트베드 프록시 사용 여부(포탈 djb.gateway.use-proxy / token-use-proxy). DJB_CTX 미지정 시 기본 true.
function useProxyOn() { return !(window.DJB_CTX && window.DJB_CTX.useProxy === false); }
function tokenProxyOn() { return !(window.DJB_CTX && window.DJB_CTX.tokenUseProxy === false); }
// 인증 스킴 → 실제 인증방식 라벨/색상. GW enricher 는 oauth·api_key 를 모두 apiKey-in-header 로 모델링하므로
// scheme.type("apiKey")만으로는 구분 불가 → authtype(djbAuthType) 및 scheme key(djbOAuth/djbApiKey)로 판별.
function schemeAuthLabel(s) {
var at = (djbAuthType || '').trim().toLowerCase();
if (at === 'oauth' || at === 'oauth2' || at === 'ca' || s.key === 'djbOAuth') {
return { label: 'OAUTH', cls: 'bg-purple-100 text-purple-700' };
}
if (at === 'api_key' || at === 'apikey' || s.key === 'djbApiKey') {
return { label: 'API KEY', cls: 'bg-emerald-100 text-emerald-700' };
}
return { label: (s.type || '').toUpperCase(), cls: 'bg-slate-100 text-slate-700' };
}
function renderAuthSection() { function renderAuthSection() {
var schemes = state.data.securitySchemes || []; var schemes = state.data.securitySchemes || [];
var proxyOn = useProxyOn();
var notice =
'<div class="mb-3 text-xs bg-slate-50 border border-slate-200 rounded p-2 leading-relaxed text-slate-600">'
+ '<div>Swagger 테스트 호출: <b>' + (proxyOn ? '포탈 프록시 경유' : '직접 호출') + '</b>'
+ (proxyOn ? ' — <code class="font-mono">/api/call-api</code> 가 인증 토큰·헤더를 대신 주입합니다.'
: ' — 브라우저가 대상 주소로 직접 호출(대상 CORS 허용 필요).') + '</div>'
+ '<div>토큰 발급: <b>' + (tokenProxyOn() ? '포탈 프록시 경유' : '직접 호출') + '</b></div>'
+ '<div class="text-slate-400 mt-1">프록시 여부는 포탈 설정 <code class="font-mono">djb.gateway.use-proxy</code> / <code class="font-mono">djb.gateway.token-use-proxy</code> 로 제어됩니다.</div>'
+ '</div>';
return card( return card(
sectionTitle('인증 / 보안', '게이트웨이(GW)에 설정된 인증만 표시됩니다. (읽기 전용)') + sectionTitle('인증 / 보안', '게이트웨이(GW)에 설정된 인증만 표시됩니다. (읽기 전용)') +
notice +
(schemes.length (schemes.length
? schemes.map((s, i) => renderSecuritySchemeCard(s, i)).join('') ? schemes.map((s, i) => renderSecuritySchemeCard(s, i)).join('')
: '<div class="text-sm text-slate-500 py-2">인증 없음</div>') : '<div class="text-sm text-slate-500 py-2">인증 없음</div>')
); );
} }
function renderSecuritySchemeCard(s, idx) { function renderSecuritySchemeCard(s, idx) {
const typeBadge = { const auth = schemeAuthLabel(s);
apiKey: 'bg-emerald-100 text-emerald-700',
http: 'bg-blue-100 text-blue-700',
oauth2: 'bg-purple-100 text-purple-700',
openIdConnect: 'bg-slate-100 text-slate-700'
}[s.type] || 'bg-slate-100 text-slate-700';
let body = ''; let body = '';
if (s.type === 'apiKey') { if (s.type === 'apiKey') {
@@ -773,7 +824,7 @@
<div class="border border-slate-200 rounded-lg p-3 mb-3 locked-card"> <div class="border border-slate-200 rounded-lg p-3 mb-3 locked-card">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<code class="text-sm font-semibold text-slate-800">${escapeHtml(s.key)}</code> <code class="text-sm font-semibold text-slate-800">${escapeHtml(s.key)}</code>
<span class="px-2 py-0.5 text-[10px] uppercase rounded ${typeBadge}">${escapeHtml(s.type)}</span> <span class="px-2 py-0.5 text-[10px] uppercase rounded ${auth.cls}">${escapeHtml(auth.label)}</span>
<span class="text-xs text-slate-400 ml-1">🔒 GW</span> <span class="text-xs text-slate-400 ml-1">🔒 GW</span>
</div> </div>
${body} ${body}
@@ -930,14 +981,27 @@
} }
} }
// 응답유형별 실제 호출 주소. 경로(op.path.value)에 어댑터경로가 이미 포함, 호스트만 응답유형별로 앞에 붙임. // 전체 URL → { origin, path }. Mock Server URL 을 서버+경로로 분해(경로를 별도로 덧붙이지 않기 위함).
function splitFullUrl(u) {
u = (u || '').trim();
if (!u) return { origin: '', path: '/' };
try {
var parsed = new URL(u);
var path = (parsed.pathname || '/') + (parsed.search || '');
if (!path || path.charAt(0) !== '/') path = '/' + path;
return { origin: parsed.origin, path: path };
} catch (e) {
// 스킴 누락/파싱 실패 → 통째 origin 취급, path 는 '/'
return { origin: u.replace(/\/+$/, ''), path: '/' };
}
}
// 응답유형별 실제 호출 주소. gw 는 [호스트 + 경로], mock 은 입력한 전체 URL 그대로(경로 미부착).
function resolvedCallUrl() { function resolvedCallUrl() {
var d = state.data; var d = state.data;
var rt = (d.portal && d.portal.responseType) || 'sample'; var rt = (d.portal && d.portal.responseType) || 'sample';
var host; if (rt === 'mock') return (((d.portal && d.portal.mockUrl) || '').trim()) || '(주소 없음)';
if (rt === 'gw') host = (window.DJB_CTX && window.DJB_CTX.gwAddress) || 'http://127.0.0.1:39310'; var host = (rt === 'gw') ? ((window.DJB_CTX && window.DJB_CTX.gwAddress) || 'http://127.0.0.1:39310') : '';
else if (rt === 'mock') host = (d.portal && d.portal.mockUrl) || '';
else host = '';
var path = (d.operation && d.operation.path && d.operation.path.value) || ''; var path = (d.operation && d.operation.path && d.operation.path.value) || '';
if (!host) return path || '(주소 없음)'; if (!host) return path || '(주소 없음)';
return host.replace(/\/+$/, '') + (path.charAt(0) === '/' ? path : '/' + path); return host.replace(/\/+$/, '') + (path.charAt(0) === '/' ? path : '/' + path);
@@ -960,24 +1024,26 @@
root.innerHTML = root.innerHTML =
card( card(
sectionTitle('포탈 게시 정보', '개발자포탈 공개 설정. 기존 API 스펙 화면의 값을 불러와 적용합니다.') + sectionTitle('포탈 게시 정보', '개발자포탈 공개 설정. 기존 API 스펙 화면의 값을 불러와 적용합니다.') +
'<div class="border border-slate-200 rounded-lg divide-y divide-slate-200">' +
fieldRow('API 그룹', fieldRow('API 그룹',
`<div class="flex flex-col gap-1"> `<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<span id="group-chips">${groupChips || '<span class="text-xs text-slate-400">선택된 그룹 없음</span>'}</span> <span id="group-chips">${groupChips || '<span class="text-xs text-slate-400">선택된 그룹 없음</span>'}</span>
<button type="button" id="btn-select-group" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button> <button type="button" id="btn-select-group" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
</div> </div>
<div class="text-xs text-slate-400">API 그룹을 설정하지 않으면 포탈에 게시되지 않습니다.</div>
<div class="text-xs text-slate-400">API 그룹은 apiGroupMan(API 그룹 관리)에서도 관리됩니다.</div> <div class="text-xs text-slate-400">API 그룹은 apiGroupMan(API 그룹 관리)에서도 관리됩니다.</div>
</div>`) + </div>`, { bordered: true }) +
fieldRow('공개 여부', `<label class="inline-flex items-center gap-2 text-sm">${checkbox(p.displayYn === 'Y', 'data-portal-yn')} 공개</label>`) + fieldRow('포탈 게시 활성화', `<label class="inline-flex items-center gap-2 text-sm">${checkbox(p.displayYn === 'Y', 'data-portal-yn')} 활성화</label>`, { bordered: true }) +
fieldRow('공개 한', fieldRow('역할별 공개 한',
`<div class="flex flex-col gap-1"> `<div class="flex flex-col gap-1">
<div class="flex gap-4"> <div class="flex gap-4">
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_USER, 'data-role="ROLE_USER"')} 개인사용자</label> <label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_USER, 'data-role="ROLE_USER"')} 개인사용자</label>
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_USER, 'data-role="ROLE_CORP_USER"')} 법인사용자</label> <label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_USER, 'data-role="ROLE_CORP_USER"')} 법인사용자</label>
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_MANAGER, 'data-role="ROLE_CORP_MANAGER"')} 법인관리자</label> <label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_MANAGER, 'data-role="ROLE_CORP_MANAGER"')} 법인관리자</label>
</div> </div>
<div class="text-xs text-slate-400">전부 선택 시 로그인 사용자 전체에게 공개니다.</div> <div class="text-xs text-slate-400">선택시 로그인 사용자에게 공개 되지 않습니다.</div>
</div>`) + </div>`, { bordered: true }) +
(showOrg ? fieldRow('공개 법인', (showOrg ? fieldRow('공개 법인',
`<div class="flex flex-col gap-1"> `<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
@@ -985,28 +1051,35 @@
<button type="button" id="btn-select-org" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button> <button type="button" id="btn-select-org" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
</div> </div>
<div class="text-xs text-slate-400">특정 법인에게만 공개하고 싶을 때 공개 법인을 선택하시면 됩니다.</div> <div class="text-xs text-slate-400">특정 법인에게만 공개하고 싶을 때 공개 법인을 선택하시면 됩니다.</div>
</div>`) : '') + </div>`, { bordered: true }) : '') +
fieldRow('응답 유형', fieldRow('응답 유형',
'<div>' + '<div>' +
['sample', 'mock', 'gw'].map(v => ['sample', 'mock', 'gw'].map(v =>
`<label class="inline-flex items-center gap-1 text-sm mr-3"><input type="radio" name="portal-rt" data-portal-rt="${v}" ${rt === v ? 'checked' : ''}> ${v === 'gw' ? 'GW' : v}</label>` `<label class="inline-flex items-center gap-1 text-sm mr-3"><input type="radio" name="portal-rt" data-portal-rt="${v}" ${rt === v ? 'checked' : ''}> ${v === 'gw' ? 'GW' : v}</label>`
).join('') + ).join('') +
`<div class="mt-2 text-xs text-slate-500">실제 호출 주소: <code id="resolved-call-url" class="font-mono text-slate-700 break-all">${escapeHtml(resolvedCallUrl())}</code></div>` + (rt === 'sample'
? `<div class="mt-2 text-xs text-slate-500">응답 샘플을 바로 리턴합니다.</div>`
: `<div class="mt-2 text-xs text-slate-500">실제 호출 주소: <code id="resolved-call-url" class="font-mono text-slate-700 break-all">${escapeHtml(resolvedCallUrl())}</code></div>`
+ `<div class="mt-1 text-xs text-slate-500">호출 방식: <b>${useProxyOn() ? '포탈 프록시 경유' : '직접 호출'}</b>`
+ (useProxyOn() ? ' (<code class="font-mono">/api/call-api</code> 경유, 인증 헤더 자동 주입)' : ' (브라우저 → 대상 직접 호출, 대상 CORS 허용 필요)') + `</div>`) +
(rt === 'gw' ? `<div class="mt-1 text-xs text-slate-400">GW 주소는 포탈 설정(PTL_PROPERTY, 그룹 Portal)의 <code class="font-mono">djb.gateway.base-url</code> 값을 사용합니다.</div>` : '') + (rt === 'gw' ? `<div class="mt-1 text-xs text-slate-400">GW 주소는 포탈 설정(PTL_PROPERTY, 그룹 Portal)의 <code class="font-mono">djb.gateway.base-url</code> 값을 사용합니다.</div>` : '') +
'</div>') + '</div>', { bordered: true }) +
(rt === 'mock' ? fieldRow('Mock Server URL', input(p.mockUrl || '', 'data-portal="mockUrl"', { placeholder: 'https://mock.example.com' }), { hint: 'Mock 응답을 제공할 서버의 기본 URL. 응답 유형이 Mock 일 때 개발자포탈이 이 주소로 요청을 전달하고, Swagger 스펙의 서버 주소로도 사용됩니다.' }) : '') (rt === 'mock' ? fieldRow('Mock Server URL', input(p.mockUrl || '', 'data-portal="mockUrl"', { placeholder: 'https://mock.example.com/api/v1/foo' }), { hint: 'Mock 응답을 제공할 전체 호출 URL(경로 포함). 응답 유형이 Mock 일 때 이 주소로 그대로 호출하며, 어댑터/오퍼레이션 경로를 뒤에 덧붙이지 않습니다.', bordered: true }) : '') +
'</div>'
) + ) +
card( card(
sectionTitle('설명 (API 소개 페이지)', 'API 소개 페이지에만 표시됩니다. OpenAPI 스펙 파일에는 포함되지 않습니다.') + sectionTitle('설명 (API 소개 페이지)', 'API 소개 페이지에만 표시됩니다. OpenAPI 스펙 파일에는 포함되지 않습니다.') +
'<div id="info-desc-editor">' + (state.data.info.description.value || '') + '</div>' '<div id="info-desc-editor">' + (state.data.info.description.value || '') + '</div>'
) + ) +
// 내보내기 카드는 PTL_PROPERTY(djb.openapi.export.enabled=true) 일 때만 노출. 기본 미노출.
((window.DJB_CTX && window.DJB_CTX.exportEnabled) ?
card( card(
sectionTitle('내보내기', 'OpenAPI 스펙을 파일로 저장합니다.') + sectionTitle('내보내기', 'OpenAPI 스펙을 파일로 저장합니다.') +
`<div class="flex gap-2"> `<div class="flex gap-2">
<button data-export="yaml" class="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">YAML 다운로드</button> <button data-export="yaml" class="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">YAML 다운로드</button>
<button data-export="json" class="px-4 py-2 text-sm rounded border border-brand-300 text-brand-700 hover:bg-brand-50">JSON 다운로드</button> <button data-export="json" class="px-4 py-2 text-sm rounded border border-brand-300 text-brand-700 hover:bg-brand-50">JSON 다운로드</button>
</div>` </div>`
); ) : '');
initInfoDescEditor(); // 설명 리치 에디터 (API 소개 페이지 전용) initInfoDescEditor(); // 설명 리치 에디터 (API 소개 페이지 전용)
} }
@@ -1586,7 +1659,7 @@
$('#btn-prev').addEventListener('click', () => goStep(state.step - 1)); $('#btn-prev').addEventListener('click', () => goStep(state.step - 1));
$('#btn-next').addEventListener('click', () => { $('#btn-next').addEventListener('click', () => {
if (state.step < STEPS.length) goStep(state.step + 1); if (state.step < STEPS.length) goStep(state.step + 1);
else toast('모든 단계를 완료했습니다. 우측 미리보기 또는 내보내기를 사용하세요.'); else saveSpec(false); // 마지막 단계의 "완료" → 저장 동작
}); });
$('#btn-toggle-preview').addEventListener('click', () => setPreviewVisibility(false)); $('#btn-toggle-preview').addEventListener('click', () => setPreviewVisibility(false));
@@ -1619,7 +1692,10 @@
toast('변경을 취소했습니다'); toast('변경을 취소했습니다');
}); });
$('#btn-export').addEventListener('click', () => $('#export-menu').classList.toggle('hidden')); // 내보내기 버튼은 djb.openapi.export.enabled=true 일 때만 DOM 에 존재 → 없으면 바인딩 생략
var bExport = $('#btn-export');
if (bExport) {
bExport.addEventListener('click', () => $('#export-menu').classList.toggle('hidden'));
document.addEventListener('click', e => { document.addEventListener('click', e => {
if (!e.target.closest('#btn-export') && !e.target.closest('#export-menu')) $('#export-menu').classList.add('hidden'); if (!e.target.closest('#btn-export') && !e.target.closest('#export-menu')) $('#export-menu').classList.add('hidden');
}); });
@@ -1627,6 +1703,7 @@
const b = e.target.closest('[data-export]'); const b = e.target.closest('[data-export]');
if (b) { downloadSpec(b.dataset.export); $('#export-menu').classList.add('hidden'); } if (b) { downloadSpec(b.dataset.export); $('#export-menu').classList.add('hidden'); }
}); });
}
var bSave = $('#btn-save'); if (bSave) bSave.addEventListener('click', () => saveSpec(false)); var bSave = $('#btn-save'); if (bSave) bSave.addEventListener('click', () => saveSpec(false));
var bRegen = $('#btn-regen'); if (bRegen) bRegen.addEventListener('click', regenerate); var bRegen = $('#btn-regen'); if (bRegen) bRegen.addEventListener('click', regenerate);
@@ -1668,7 +1745,12 @@
example: wrap(p.example == null ? '' : p.example), children: null example: wrap(p.example == null ? '' : p.example), children: null
}; };
if (t === 'object') row.children = schemaToRows(p); if (t === 'object') row.children = schemaToRows(p);
else if (t === 'array') row.itemsType = wrap((p.items && p.items.type) || 'string'); else if (t === 'array') {
var itemType = (p.items && p.items.type) || 'string';
row.itemsType = wrap(itemType);
// array<object>(GW GRID): items.properties 를 자식 행으로 복원. (누락 시 자식 필드 유실)
if (itemType === 'object') row.children = schemaToRows(p.items);
}
return row; return row;
} }
function schemaToRows(schema) { function schemaToRows(schema) {
@@ -1906,6 +1988,7 @@
if (res && res.testbedSpec) { if (res && res.testbedSpec) {
try { state.data = specToData(JSON.parse(res.testbedSpec)); } catch (e) { console.error('specToData', e); } try { state.data = specToData(JSON.parse(res.testbedSpec)); } catch (e) { console.error('specToData', e); }
} }
djbAuthType = (res && res.authType) || ''; // 인증 badge 라벨 판별용(oauth/api_key)
// 자동생성 규칙(title/tag/summary=API명, 예제, 서버prepend, opId)은 백엔드에서 적용됨 → 그대로 로드 // 자동생성 규칙(title/tag/summary=API명, 예제, 서버prepend, opId)은 백엔드에서 적용됨 → 그대로 로드
state.step = 1; state.responseStatusTab = state.data.responses && state.data.responses['200'] ? '200' : (state.data.responses ? Object.keys(state.data.responses)[0] : '200'); state.step = 1; state.responseStatusTab = state.data.responses && state.data.responses['200'] ? '200' : (state.data.responses ? Object.keys(state.data.responses)[0] : '200');
var _nm = decodeEntities(ctx.apiName || '') || state.data.info.title.value || ''; var _nm = decodeEntities(ctx.apiName || '') || state.data.info.title.value || '';
@@ -1923,12 +2006,16 @@
if (res.responseType) pp.responseType = res.responseType; if (res.responseType) pp.responseType = res.responseType;
if (res.mockUrl) pp.mockUrl = decodeEntities(res.mockUrl); if (res.mockUrl) pp.mockUrl = decodeEntities(res.mockUrl);
if (res.apiGroups) { try { pp.apiGroups = JSON.parse(res.apiGroups) || []; } catch (e) { pp.apiGroups = []; } } if (res.apiGroups) { try { pp.apiGroups = JSON.parse(res.apiGroups) || []; } catch (e) { pp.apiGroups = []; } }
// 설명(API 소개 페이지) — spec 에 미포함, description 컬럼에 별도 저장 → 별도 필드로 복원
if (res.description) state.data.info.description = wrap(decodeEntities(res.description));
// 요청/응답 메시지 스펙(HTML) — 저장본은 저장값, 자동생성분은 프론트에서 생성 // 요청/응답 메시지 스펙(HTML) — 저장본은 저장값, 자동생성분은 프론트에서 생성
state.data.msgSpec = state.data.msgSpec || { request: '', response: '' }; state.data.msgSpec = state.data.msgSpec || { request: '', response: '' };
if (res.apiRequestSpec) state.data.msgSpec.request = decodeEntities(res.apiRequestSpec); if (res.apiRequestSpec) state.data.msgSpec.request = decodeEntities(res.apiRequestSpec);
if (res.apiResponseSpec) state.data.msgSpec.response = decodeEntities(res.apiResponseSpec); if (res.apiResponseSpec) state.data.msgSpec.response = decodeEntities(res.apiResponseSpec);
// 저장본이 아니면(자동생성/최초 성) 추가자료(예제·설명자료)를 프론트에서 스키마 기준 생성 // 저장본이 아니면(최초 성) 자동생성 여부를 confirm 으로 확인 후 진행
if ((res.source || '') !== 'saved') frontAutoGenerate(); if ((res.source || '') !== 'saved') {
if (window.confirm('저장된 스펙이 없습니다. 게이트웨이 레이아웃 기반으로 예제·설명자료를 자동 생성할까요?')) frontAutoGenerate();
}
}) })
.catch(e => console.error('loadInitialData', e)); .catch(e => console.error('loadInitialData', e));
} }
@@ -1985,6 +2072,7 @@
try { state.data = specToData(JSON.parse(res.testbedSpec)); } try { state.data = specToData(JSON.parse(res.testbedSpec)); }
catch (e) { toast('자동 생성 실패: 스펙 파싱 오류', 'error'); return; } catch (e) { toast('자동 생성 실패: 스펙 파싱 오류', 'error'); return; }
} }
djbAuthType = (res && res.authType) || ''; // 인증 badge 라벨 판별용(oauth/api_key)
// 구조(title/tag/summary/opId/스키마/보안/헤더)는 백엔드(generateSpec)가 적용한 기초 스펙 그대로 로드. // 구조(title/tag/summary/opId/스키마/보안/헤더)는 백엔드(generateSpec)가 적용한 기초 스펙 그대로 로드.
// 추가자료(예제·설명자료)는 프론트에서 스키마 기준 생성 → 재생성 버튼과 동일 결과. // 추가자료(예제·설명자료)는 프론트에서 스키마 기준 생성 → 재생성 버튼과 동일 결과.
frontAutoGenerate(); frontAutoGenerate();
+32 -267
View File
@@ -1,298 +1,63 @@
// 계좌이체 API 모의 데이터 // OpenAPI 에디터 초기 상태 골격(빈 껍데기).
// locked: true 인 필드는 게이트웨이가 자동 주입한 값(회색 + 자물쇠) // - specToData() 가 deepClone 하여 서버 spec 값으로 덮어쓰는 구조 기준값.
// locked: false / 미지정 인 필드는 사용자가 자유롭게 편집 // - 실제 데모/샘플 값은 두지 않는다(팝업은 항상 서버 spec 로드 후 렌더).
// - docOptions 만 기능 기본값으로 유지(문서 미리보기 옵션).
// locked:true = 게이트웨이 자동 주입(회색+자물쇠) / locked:false = 사용자 편집.
window.SAMPLE_DATA = (function () { window.SAMPLE_DATA = (function () {
function f() { return { value: '', locked: false }; }
return { return {
info: { info: {
title: { value: '계좌이체 API', locked: false }, title: f(),
version: { value: '1.0.0', locked: false }, version: f(),
summary: { value: '', locked: false }, summary: f(),
description: { value: '', locked: false }, description: f(),
termsOfService: { value: '', locked: false }, termsOfService: f(),
contact: { contact: { name: f(), email: f(), url: f() },
name: { value: 'DJB API Team', locked: false }, license: { name: f(), url: f() }
email: { value: 'api@djb.co.kr', locked: false },
url: { value: '', locked: false }
},
license: {
name: { value: '', locked: false },
url: { value: '', locked: false }
}
}, },
tags: [ tags: [],
{ name: 'transfer', description: '계좌이체 관련 API' }
],
externalDocs: { externalDocs: { description: f(), url: f() },
description: { value: '', locked: false },
url: { value: '', locked: false }
},
servers: [ servers: [],
{
url: { value: 'https://api-dev.djb.co.kr', locked: true },
env: 'development',
description: { value: '개발 환경', locked: false }
},
{
url: { value: 'https://api-stg.djb.co.kr', locked: true },
env: 'staging',
description: { value: '스테이징 환경', locked: false }
},
{
url: { value: 'https://api.djb.co.kr', locked: true },
env: 'production',
description: { value: '운영 환경', locked: false }
}
],
serverVariables: [ serverVariables: [],
{
name: { value: 'basePath', locked: true },
default: { value: '/v1', locked: false },
enumStr: { value: '/v1,/v2', locked: false },
description: { value: 'API 버전 경로', locked: false }
}
],
operation: { operation: {
method: { value: 'POST', locked: true }, method: f(),
path: { value: '/v1/accounts/transfer', locked: true }, path: f(),
operationId: { value: 'transferAccount', locked: false }, operationId: f(),
summary: { value: '', locked: false }, summary: f(),
description: { value: '', locked: false }, description: f(),
tags: { value: ['transfer'], locked: false }, tags: { value: [], locked: false },
deprecated: { value: false, locked: false } deprecated: { value: false, locked: false }
}, },
// 파라미터 (Path/Query/Header/Cookie) // 파라미터 (Path/Query/Header/Cookie)
parameters: [ parameters: [],
{
in: 'header',
name: { value: 'X-Request-Id', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: 'uuid', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '', locked: false }
}
],
// Request Body 스키마 (중첩 3단) // Request Body 스키마
requestBody: { requestBody: {
mediaType: 'application/json', mediaType: 'application/json',
required: { value: true, locked: true }, required: { value: false, locked: false },
schema: [ schema: []
{
name: { value: 'transactionId', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '40', locked: false },
pattern: { value: '^tx-[0-9a-zA-Z-]+$', locked: false },
description: { value: '', locked: false },
example: { value: 'tx-20260522-001', locked: false },
children: null
},
{
name: { value: 'sender', locked: true },
type: { value: 'object', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '송금인 정보', locked: false },
example: { value: '', locked: false },
children: [
{
name: { value: 'accountNumber', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '20', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '110-1234-567890', locked: false },
children: null
},
{
name: { value: 'bankCode', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '3', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '088', locked: false },
children: null
},
{
name: { value: 'holderName', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '60', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '김철수', locked: false },
children: null
}
]
},
{
name: { value: 'receiver', locked: true },
type: { value: 'object', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '수취인 정보', locked: false },
example: { value: '', locked: false },
children: [
{
name: { value: 'accountNumber', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '20', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '111-2345-678901', locked: false },
children: null
},
{
name: { value: 'bankCode', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '3', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '020', locked: false },
children: null
},
{
name: { value: 'holderName', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '60', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '이영희', locked: false },
children: null
}
]
},
{
name: { value: 'amount', locked: true },
type: { value: 'object', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '이체 금액', locked: false },
example: { value: '', locked: false },
children: [
{
name: { value: 'value', locked: true },
type: { value: 'integer', locked: true },
required: { value: true, locked: true },
format: { value: 'int64', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '50000', locked: false },
children: null
},
{
name: { value: 'currency', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '3', locked: false },
pattern: { value: '^[A-Z]{3}$', locked: false },
description: { value: 'ISO 4217 통화코드', locked: false },
example: { value: 'KRW', locked: false },
children: null
}
]
},
{
name: { value: 'memo', locked: true },
type: { value: 'string', locked: true },
required: { value: false, locked: true },
format: { value: '', locked: false },
maxLength: { value: '100', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '5월 회비', locked: false },
children: null
}
]
}, },
// 응답 스키마 (상태코드 별) // 응답 스키마 (상태코드 별)
responses: { responses: {
'200': { '200': { description: f(), schema: [] }
description: { value: '이체 성공', locked: false },
schema: [
{ name: { value: 'transactionId', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: 'tx-20260522-001', locked: false }, children: null },
{ name: { value: 'status', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: 'COMPLETED', locked: false }, children: null },
{ name: { value: 'completedAt', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: 'date-time', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '2026-05-22T10:00:00Z', locked: false }, children: null },
{
name: { value: 'balance', locked: true }, type: { value: 'object', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '잔액 정보', locked: false }, example: { value: '', locked: false },
children: [
{ name: { value: 'before', locked: true }, type: { value: 'integer', locked: true }, required: { value: true, locked: true }, format: { value: 'int64', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '1000000', locked: false }, children: null },
{ name: { value: 'after', locked: true }, type: { value: 'integer', locked: true }, required: { value: true, locked: true }, format: { value: 'int64', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '950000', locked: false }, children: null }
]
}
]
},
'400': {
description: { value: '잘못된 요청', locked: false },
schema: [
{
name: { value: 'error', locked: true }, type: { value: 'object', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '', locked: false },
children: [
{ name: { value: 'code', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: 'INVALID_AMOUNT', locked: false }, children: null },
{ name: { value: 'message', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '금액이 올바르지 않습니다', locked: false }, children: null },
{ name: { value: 'details', locked: true }, type: { value: 'array', locked: true }, required: { value: false, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '', locked: false }, itemsType: { value: 'string', locked: false }, children: null }
]
}
]
},
'401': { description: { value: '인증 실패', locked: false }, schema: [] },
'500': { description: { value: '서버 오류', locked: false }, schema: [] }
}, },
// 보안 스킴 // 보안 스킴
securitySchemes: [ securitySchemes: [],
{ globalSecurity: [],
key: 'ApiKeyAuth',
type: 'apiKey',
locked: true,
name: { value: 'X-API-Key', locked: true },
in: { value: 'header', locked: true },
description: { value: '', locked: false }
}
],
globalSecurity: ['ApiKeyAuth'],
// 예제 // 예제
examples: { examples: {
request: { request: {},
'application/json': { response: {}
default: '{\n "transactionId": "tx-20260522-001",\n "sender": {\n "accountNumber": "110-1234-567890",\n "bankCode": "088",\n "holderName": "김철수"\n },\n "receiver": {\n "accountNumber": "111-2345-678901",\n "bankCode": "020",\n "holderName": "이영희"\n },\n "amount": {\n "value": 50000,\n "currency": "KRW"\n },\n "memo": "5월 회비"\n}'
}
},
response: {
'200': { body: '{\n "transactionId": "tx-20260522-001",\n "status": "COMPLETED"\n}', headers: [ { name: 'X-RateLimit-Remaining', type: 'integer', description: '남은 요청 횟수', example: '99' } ] }
}
}, },
// 문서 옵션 // 문서 옵션
@@ -184,8 +184,9 @@ function getEndTime(){
</td> </td>
<th style="width:180px;"><%= localeMessage.getString("auditLogMan.remoteAddress") %></th> <th style="width:180px;"><%= localeMessage.getString("auditLogMan.remoteAddress") %></th>
<td><input type="text" name="searchRemoteAddress" value="${param.searchRemoteAddress}" style="width:100%"></td> <td><input type="text" name="searchRemoteAddress" value="${param.searchRemoteAddress}" style="width:100%"></td>
<th style="width:180px;"></th> <th style="width:180px;"><%= localeMessage.getString("auditLogMan.parameters") %></th>
<td> <td>
<input type="text" name="searchParameters" value="${param.searchParameters}" style="width:100%">
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -54,10 +54,18 @@ function detail(key){
$('#systemCode').val(data.systemCode); $('#systemCode').val(data.systemCode);
$('#logTypeText').val(data.logTypeText); $('#logTypeText').val(data.logTypeText);
$('#remoteAddress').val(data.remoteAddress); $('#remoteAddress').val(data.remoteAddress);
$('#logMsg').val(data.message);
$('#logSubMsg').val(data.command); $('#logSubMsg').val(data.command);
$('#parameters').val(data.parameters); $('#parameters').val(data.parameters);
// 사유(message)는 값이 있을 때만 노출
if (data.message) {
$('#logMsg').val(data.message);
$('#messageRow').show();
} else {
$('#logMsg').val('');
$('#messageRow').hide();
}
}, },
error:function(e){ error:function(e){
alert(e.responseText); alert(e.responseText);
@@ -121,6 +129,9 @@ $(document).ready(function() {
<tr> <tr>
<th><%= localeMessage.getString("auditLogMan.command") %></th><td><input type="text" id="logSubMsg" name="logSubMsg" readonly="readonly"/></td> <th><%= localeMessage.getString("auditLogMan.command") %></th><td><input type="text" id="logSubMsg" name="logSubMsg" readonly="readonly"/></td>
</tr> </tr>
<tr id="messageRow" style="display:none;">
<th><%= localeMessage.getString("auditLogMan.message") %></th><td><textarea id="logMsg" name="logMsg" style="width:100%;height:80px" readonly="readonly"></textarea></td>
</tr>
<tr height="100px"> <tr height="100px">
<th><%= localeMessage.getString("auditLogMan.parameters") %></th><td><textarea id="parameters" name="parameters" style="width:100%;height:200px" readonly="readonly"></textarea></td> <th><%= localeMessage.getString("auditLogMan.parameters") %></th><td><textarea id="parameters" name="parameters" style="width:100%;height:200px" readonly="readonly"></textarea></td>
</tr> </tr>
@@ -0,0 +1,120 @@
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<%@ page import="java.util.List"%>
<%@ page import="com.eactive.eai.rms.common.acl.sitemap.ui.SitemapNode"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%!
private void printNodeLabel(javax.servlet.jsp.JspWriter out, SitemapNode node) throws java.io.IOException {
String url = node.getMenuUrl();
boolean hasLink = url != null && !url.trim().isEmpty() && !"NAN".equalsIgnoreCase(url.trim());
if (hasLink) {
out.print("<a href=\"javascript:void(0);\" class=\"sitemap-name\" onclick=\"goPage('"
+ escapeJs(url) + "','" + escapeJs(node.getMenuId()) + "');return false;\">"
+ escapeHtml(node.getMenuName()) + "</a>");
} else {
out.print("<span class=\"sitemap-name\">" + escapeHtml(node.getMenuName()) + "</span>");
}
}
private void printSitemapNode(javax.servlet.jsp.JspWriter out, SitemapNode node) throws java.io.IOException {
out.print("<li>");
printNodeLabel(out, node);
List<SitemapNode> children = node.getChildren();
if (children != null && !children.isEmpty()) {
out.print("<ul>");
for (SitemapNode child : children) {
printSitemapNode(out, child);
}
out.print("</ul>");
}
out.print("</li>");
}
private String escapeHtml(String value) {
if (value == null) {
return "";
}
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
private String escapeJs(String value) {
if (value == null) {
return "";
}
return value.replace("\\", "\\\\").replace("'", "\\'");
}
%>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
List<SitemapNode> sitemapTree = (List<SitemapNode>) request.getAttribute("sitemapTree");
%>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<jsp:include page="/jsp/common/include/script.jsp"/>
<style>
.sitemap-columns { display: flex; flex-wrap: wrap; gap: 40px; }
.sitemap-column { min-width: 180px; }
.sitemap-category { font-size: 1.1em; font-weight: bold; padding-bottom: 6px; margin-bottom: 8px; border-bottom: 2px solid #ccc; }
.sitemap-tree, .sitemap-tree ul { list-style-type: disc; margin: 0; padding-left: 18px; }
.sitemap-tree { padding-left: 0; list-style-type: none; }
.sitemap-tree li { line-height: 1.8; }
a.sitemap-name { text-decoration: none; cursor: pointer; }
a.sitemap-name:hover { text-decoration: underline; }
</style>
<script language="javascript">
function goPage(url, menuId) {
var serviceType = sessionStorage["serviceType"];
var pageUrl = url;
pageUrl += (pageUrl.indexOf("?") > -1 ? "&" : "?") + "menuId=" + menuId;
pageUrl += "&serviceType=" + serviceType;
parent.leftFrame.location.href = '<c:url value="/leftMenu.do"/>?menuId=' + menuId + '&serviceType=' + serviceType;
location.href = pageUrl;
}
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path">
<li><a href="#">${rmsMenuPath}</a></li>
</ul>
</div><!-- end content_top -->
<div class="content_middle">
<!-- <div class="title">사이트맵</div> -->
<div class="sitemap-columns">
<%
for (SitemapNode category : sitemapTree) {
%>
<div class="sitemap-column">
<div class="sitemap-category">
<%
printNodeLabel(out, category);
%>
</div>
<ul class="sitemap-tree">
<%
List<SitemapNode> children = category.getChildren();
if (children != null) {
for (SitemapNode child : children) {
printSitemapNode(out, child);
}
}
%>
</ul>
</div>
<%
}
%>
</div>
</div><!-- end content_middle -->
</div><!-- end right_box -->
</body>
</html>
+18 -4
View File
@@ -18,6 +18,16 @@
<script language="javascript" > <script language="javascript" >
var url = '<c:url value="/common/acl/user/userMan.json" />'; var url = '<c:url value="/common/acl/user/userMan.json" />';
var url_view = '<c:url value="/common/acl/user/userMan.view" />'; var url_view = '<c:url value="/common/acl/user/userMan.view" />';
function userStatusFormat (cellvalue){
if ( cellvalue == '1' ){
return '<%= localeMessage.getString("code.normal") %>';
}else if ( cellvalue == '2' ) {
return '<span style="color:red"><%= localeMessage.getString("code.lock") %></span>';
}
return '';
}
$(document).ready(function() { $(document).ready(function() {
var gridPostData = getSearchForJqgrid("cmd","LIST"); //jqgrid에서는 object 로 var gridPostData = getSearchForJqgrid("cmd","LIST"); //jqgrid에서는 object 로
$('#grid').jqGrid({ $('#grid').jqGrid({
@@ -28,16 +38,20 @@ $(document).ready(function() {
colNames:['<%= localeMessage.getString("login.userId") %>', colNames:['<%= localeMessage.getString("login.userId") %>',
'<%= localeMessage.getString("user.name") %>', '<%= localeMessage.getString("user.name") %>',
'<%= localeMessage.getString("user.roleName") %>', '<%= localeMessage.getString("user.roleName") %>',
'<%= localeMessage.getString("user.status") %>',
'<%= localeMessage.getString("user.createOn") %>', '<%= localeMessage.getString("user.createOn") %>',
'<%= localeMessage.getString("user.lastLoginYms") %>',
'<%= localeMessage.getString("user.depart") %>', '<%= localeMessage.getString("user.depart") %>',
'<%= localeMessage.getString("user.teamName") %>', '<%= localeMessage.getString("user.teamName") %>',
'<%= localeMessage.getString("user.position") %>' '<%= localeMessage.getString("user.position") %>'
], ],
colModel:[ colModel:[
{ name : 'USERID' , align:'center' ,sortable:false }, { name : 'USERID' , align:'center', width: 100, sortable:false },
{ name : 'USERNAME' , align:'center' }, { name : 'USERNAME' , align:'center', width: 100 },
{ name : 'ROLEIDNFINAME' , align:'center' }, { name : 'ROLEIDNFINAME' , align:'center', width: 300 },
{ name : 'LASTAMNDYMS' , align:'center', formatter: timeStampFormat }, { name : 'STATUS' , align:'center', width: 80, formatter: userStatusFormat },
{ name : 'REGDYMS' , align:'center', width: 120, formatter: timeStampFormat },
{ name : 'LASTLOGINYMS' , align:'center', width: 120, formatter: timeStampFormat },
{ name : 'DVSNNAME' , align:'center', hidden: true }, { name : 'DVSNNAME' , align:'center', hidden: true },
{ name : 'TEAMNAME' , align:'center', hidden: true }, { name : 'TEAMNAME' , align:'center', hidden: true },
{ name : 'JOBTLNAME' , align:'center', hidden: true } { name : 'JOBTLNAME' , align:'center', hidden: true }
@@ -88,7 +88,8 @@ function detail(key){
$(tag+"[name="+name+"]").val(data[name.toUpperCase()] ? data[name.toUpperCase()].trim():""); $(tag+"[name="+name+"]").val(data[name.toUpperCase()] ? data[name.toUpperCase()].trim():"");
}); });
$("input[name=LASTAMNDYMS]").inputmask("9999-99-99 99:99:99",{'autoUnmask':true}); $("input[name=REGDYMS]").inputmask("9999-99-99 99:99:99",{'autoUnmask':true});
$("input[name=LASTLOGINYMS]").inputmask("9999-99-99 99:99:99",{'autoUnmask':true});
/* $("input[name=SECONDMENTSTDT]").inputmask("9999-99-99",{'autoUnmask':true}); /* $("input[name=SECONDMENTSTDT]").inputmask("9999-99-99",{'autoUnmask':true});
$("input[name=SECONDMENTENDT]").inputmask("9999-99-99",{'autoUnmask':true}); */ $("input[name=SECONDMENTENDT]").inputmask("9999-99-99",{'autoUnmask':true}); */
@@ -216,6 +217,7 @@ $(document).ready(function() {
dataType:"json", dataType:"json",
data:{cmd: 'UPDATE_PASSWORD', userId : $("input[name=userId]").val()}, data:{cmd: 'UPDATE_PASSWORD', userId : $("input[name=userId]").val()},
success:function(json){ success:function(json){
$('select[name="status"]').val("1")
alert(json.result); alert(json.result);
}, },
error:function(e){ error:function(e){
@@ -277,10 +279,10 @@ $(document).ready(function() {
<table class="table_row" cellspacing="0"> <table class="table_row" cellspacing="0">
<input type="hidden" name="jobclcd"> <input type="hidden" name="jobclcd">
<tr> <tr>
<th style="width:180px;"><%= localeMessage.getString("login.userId") %></th><td ><input type="text" name="userId"/> </td> <th style="width:180px;"><%= localeMessage.getString("login.userId") %> <span class=\"required-mark\">*</span></th><td ><input type="text" name="userId"/> </td>
</tr> </tr>
<tr> <tr>
<th><%= localeMessage.getString("user.name") %> </th><td><input type="text" name="userName" /></td> <th><%= localeMessage.getString("user.name") %> <span class=\"required-mark\">*</span></th><td><input type="text" name="userName" /></td>
</tr> </tr>
<tr class="datailT"> <tr class="datailT">
<th><%= localeMessage.getString("user.auth") %> </th><td><input type="text" name="roleidnfiname" /></td> <th><%= localeMessage.getString("user.auth") %> </th><td><input type="text" name="roleidnfiname" /></td>
@@ -317,7 +319,6 @@ $(document).ready(function() {
<td> <td>
<div class="select-style"> <div class="select-style">
<select name="status"> <select name="status">
<option value=""><%= localeMessage.getString("code.normal") %></option>
<option value="1"><%= localeMessage.getString("code.normal") %></option> <option value="1"><%= localeMessage.getString("code.normal") %></option>
<option value="2"><%= localeMessage.getString("code.lock") %></option> <option value="2"><%= localeMessage.getString("code.lock") %></option>
</select> </select>
@@ -334,7 +335,10 @@ $(document).ready(function() {
<th><%= localeMessage.getString("user.fieldAgentName") %> </th><td><input type="text" name="spotprxyname" /></td> <th><%= localeMessage.getString("user.fieldAgentName") %> </th><td><input type="text" name="spotprxyname" /></td>
</tr> --%> </tr> --%>
<tr> <tr>
<th><%= localeMessage.getString("user.createOn") %> </th><td><input type="text" name="LASTAMNDYMS" readonly/></td> <th><%= localeMessage.getString("user.createOn") %> </th><td><input type="text" name="REGDYMS" readonly/></td>
</tr>
<tr>
<th><%= localeMessage.getString("user.lastLoginYms") %> </th><td><input type="text" name="LASTLOGINYMS" readonly/></td>
</tr> </tr>
<%-- <tr> <%-- <tr>
<th><%= localeMessage.getString("user.secondmentbrncd") %> </th><td><input type="text" name="SECONDMENTBRNCD" readonly/></td> <th><%= localeMessage.getString("user.secondmentbrncd") %> </th><td><input type="text" name="SECONDMENTBRNCD" readonly/></td>
+82
View File
@@ -531,6 +531,88 @@
} }
}, 200); }, 200);
} }
/**
* 사유 입력 모달 - 확인 시 입력한 사유 문자열을 onConfirm(reason)으로 전달
* @param {string} message - 안내 메시지
* @param {object} options - (title, confirmText, cancelText, placeholder, required, onConfirm, onCancel)
* required 기본 true (빈 사유 차단)
*/
function showReasonPrompt(message, options) {
options = options || {};
var title = options.title || '사유 입력';
var confirmText = options.confirmText || '확인';
var cancelText = options.cancelText || '취소';
var placeholder = options.placeholder || '사유를 입력하세요.';
var required = options.required !== false;
var onConfirm = options.onConfirm || function(){};
var onCancel = options.onCancel || function(){};
// 기존 모달 제거
$('#commonReasonModal').remove();
var iconHtml = getAlertIcon('warning');
var modalHtml =
'<div id="commonReasonModal" class="common-alert-overlay">' +
'<div class="common-alert-modal alert-warning">' +
'<div class="common-alert-header">' +
'<span class="common-alert-icon">' + iconHtml + '</span>' +
'<span class="common-alert-title">' + title + '</span>' +
'</div>' +
'<div class="common-alert-body">' +
'<p class="common-alert-message">' + message + '</p>' +
'<textarea id="commonReasonInput" maxlength="200" placeholder="' + placeholder + '" style="width:100%;height:80px;margin-top:10px;box-sizing:border-box;font-size:13px;padding:8px;border:1px solid #ddd;border-radius:4px;resize:vertical;"></textarea>' +
'<p id="commonReasonError" style="display:none;color:#f44336;font-size:12px;margin:6px 0 0;">사유를 입력해 주세요.</p>' +
'</div>' +
'<div class="common-alert-footer">' +
'<button type="button" class="common-confirm-cancel-btn" id="commonReasonCancelBtn">' + cancelText + '</button>' +
'<button type="button" class="common-alert-btn" id="commonReasonOkBtn">' + confirmText + '</button>' +
'</div>' +
'</div>' +
'</div>';
$('body').append(modalHtml);
setTimeout(function() {
$('#commonReasonModal').addClass('show');
$('#commonReasonInput').focus();
}, 10);
// 확인 버튼
$('#commonReasonOkBtn').on('click', function() {
var reason = $.trim($('#commonReasonInput').val());
if (required && reason === '') {
$('#commonReasonError').show();
$('#commonReasonInput').focus();
return;
}
closeCommonReason(function() { onConfirm(reason); });
});
// 취소 버튼
$('#commonReasonCancelBtn').on('click', function() {
closeCommonReason(onCancel);
});
// ESC 키로 취소
$(document).on('keydown.commonReason', function(e) {
if (e.keyCode === 27) {
closeCommonReason(onCancel);
}
});
}
function closeCommonReason(callback) {
$('#commonReasonModal').removeClass('show');
setTimeout(function() {
$('#commonReasonModal').remove();
$(document).off('keydown.commonReason');
if (typeof callback === 'function') {
callback();
}
}, 200);
}
</script> </script>
<!-- 공용 Alert 모달 스타일 --> <!-- 공용 Alert 모달 스타일 -->
+40 -24
View File
@@ -91,7 +91,10 @@
data: $('#loginForm').serialize(), data: $('#loginForm').serialize(),
dataType: 'json', dataType: 'json',
success: function(response) { success: function(response) {
if (response.smsAuthRequired) { if (response.changePassword) {
showChangeInitPassword(response);
}
else if (response.smsAuthRequired) {
// SMS 인증 필요 // SMS 인증 필요
showSmsAuthModal(response); showSmsAuthModal(response);
} else if (response.success) { } else if (response.success) {
@@ -109,6 +112,13 @@
}); });
} }
function showChangeInitPassword(data) {
$('input[name="resetUserId"]').val($('input[name="userId"]').val());
$('input[name="resetPassword"]').val($('input[name="password"]').val());
$('#changeInitPassword').hide();
$('#pwdChgModal').modal('show');
}
// SMS 인증 모달 표시 // SMS 인증 모달 표시
function showSmsAuthModal(data) { function showSmsAuthModal(data) {
$('#smsAuthMaskedPhone').text(data.maskedPhone); $('#smsAuthMaskedPhone').text(data.maskedPhone);
@@ -218,44 +228,39 @@
clearInterval(resendTimer); clearInterval(resendTimer);
$('#smsAuthModal').modal('hide'); $('#smsAuthModal').modal('hide');
} }
function changePwd(){
function checkPwd(){
if ($("input[name=resetUserId]").val() == null || $("input[name=resetUserId]").val().length == 0 ){ if ($("input[name=resetUserId]").val() == null || $("input[name=resetUserId]").val().length == 0 ){
alert("<%= localeMessage.getString("login.checkid") %>"); alert("<%= localeMessage.getString("login.checkid") %>");
$("input[name=resetUserId]").trigger("focus"); $("input[name=resetUserId]").trigger("focus");
return; return false;
} }
if ($("input[name=resetPassword]").val() == null || $("input[name=resetPassword]").val().trim().length == 0 ){ if ($("input[name=resetPassword]").val() == null || $("input[name=resetPassword]").val().trim().length == 0 ){
alert("<%= localeMessage.getString("login.checkpwd1") %>"); alert("<%= localeMessage.getString("login.checkpwd1") %>");
$("input[name=resetPassword]").trigger("focus"); $("input[name=resetPassword]").trigger("focus");
return; return false;
} }
if ($("input[name=changePassword]").val() == null || $("input[name=changePassword]").val().trim().length == 0 ){ if ($("input[name=changePassword]").val() == null || $("input[name=changePassword]").val().trim().length == 0 ){
alert("<%= localeMessage.getString("login.checkpwd2") %>"); alert("<%= localeMessage.getString("login.checkpwd2") %>");
$("input[name=changePassword]").trigger("focus"); $("input[name=changePassword]").trigger("focus");
return ; return false;
} }
if ($("input[name=confirmPassword]").val() == null || $("input[name=confirmPassword]").val().trim().length == 0 ){ if ($("input[name=confirmPassword]").val() == null || $("input[name=confirmPassword]").val().trim().length == 0 ){
alert("<%= localeMessage.getString("login.checkpwd3") %>"); alert("<%= localeMessage.getString("login.checkpwd3") %>");
return ; return false;
} }
if ($("input[name=confirmPassword]").val() != $("input[name=confirmPassword]").val()){ if ($("input[name=changePassword]").val() != $("input[name=confirmPassword]").val()){
alert("<%= localeMessage.getString("login.checkpwd4") %>"); alert("<%= localeMessage.getString("login.checkpwd4") %>");
$("input[name=confirmPassword]").trigger("focus"); $("input[name=confirmPassword]").trigger("focus");
return ; return false;
} }
if ($("input[name=resetUserId]").val() == $("input[name=changePassword]").val()){ if ($("input[name=resetUserId]").val() == $("input[name=changePassword]").val()){
alert("<%= localeMessage.getString("login.checkpwd5") %>"); alert("<%= localeMessage.getString("login.checkpwd5") %>");
$("input[name=changePassword]").trigger("focus"); $("input[name=changePassword]").trigger("focus");
return ; return false;
} }
//if (idCheck.checked){ return true;
// setCookie("key",id);
//}
<%-- $("form").attr("action","<c:url value="/changePassword.do"/>");
//$("form").action = "<%=request.getContextPath()%>/changePassword.do";
--%>
$("form").submit();
} }
$(document).ready(function() { $(document).ready(function() {
@@ -271,14 +276,22 @@
}); });
$("input[name=changePassword]").keydown(function(event){ $("input[name=changePassword]").keydown(function(event){
if ( event.which == 13 ) { if ( event.which == 13 ) {
changePwd(); $('#modalLoginForm').submit();
} }
}); });
$("input[name=confirmPassword]").keydown(function(event){ $("input[name=confirmPassword]").keydown(function(event){
if ( event.which == 13 ) { if ( event.which == 13 ) {
changePwd(); $('#modalLoginForm').submit();
} }
}); });
// Password Change 링크로 직접 열 때만 초기화 (JS에서 modal('show') 호출 시 relatedTarget 없음)
$('#pwdChgModal').on('show.bs.modal', function(event) {
if (event.relatedTarget) {
$('#modalLoginForm')[0].reset();
$('#changeInitPassword').show();
}
});
$("select[name=serviceType]").change(function(){ $("select[name=serviceType]").change(function(){
fncPreMain(); fncPreMain();
}); });
@@ -286,9 +299,7 @@
$("#btn_login").click(function(){ $("#btn_login").click(function(){
fncPreMain(); fncPreMain();
}); });
$("#changePwd").click(function(){
changePwd();
});
$("#btn_sso_login").click(function(){ $("#btn_sso_login").click(function(){
fncSsoLogin(); fncSsoLogin();
}); });
@@ -366,7 +377,7 @@
</div> </div>
</div> </div>
<!-- 비밀번호 변경 모달 -->
<div class="modal fade" id="pwdChgModal" tabindex="-1" role="dialog" aria-labelledby="pwdChgModalLabel" aria-hidden="true"> <div class="modal fade" id="pwdChgModal" tabindex="-1" role="dialog" aria-labelledby="pwdChgModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -376,14 +387,16 @@
<span aria-hidden="true">X</span> <span aria-hidden="true">X</span>
</button> </button>
</div> </div>
<form name="form" id="modalLoginForm" action="<c:url value="/changePassword.do"/>" method="post"> <form id="modalLoginForm" action="<c:url value="/changePassword.do"/>" method="post" onsubmit="return checkPwd()" novalidate>
<div class="modal-body"> <div class="modal-body">
<div id="changeInitPassword">
<div class="form-group d-flex"> <div class="form-group d-flex">
<input type="text" name="resetUserId" class="form-control rounded-left" placeholder="User Id" required> <input type="text" name="resetUserId" class="form-control rounded-left" placeholder="User Id" required>
</div> </div>
<div class="form-group d-flex"> <div class="form-group d-flex">
<input type="password" name="resetPassword" class="form-control rounded-left" placeholder="<%= localeMessage.getString("login.placeholderCurrentPassword") %>" autocomplete="off" required> <input type="password" name="resetPassword" class="form-control rounded-left" placeholder="<%= localeMessage.getString("login.placeholderCurrentPassword") %>" autocomplete="off" required>
</div> </div>
</div>
<p><%= localeMessage.getString("login.toChangePasswordMessage") %></p> <p><%= localeMessage.getString("login.toChangePasswordMessage") %></p>
<div class="form-group d-flex"> <div class="form-group d-flex">
<input type="password" name="changePassword" class="form-control rounded-left" placeholder="<%= localeMessage.getString("login.placeholderChangePassword") %>" autocomplete="off" required> <input type="password" name="changePassword" class="form-control rounded-left" placeholder="<%= localeMessage.getString("login.placeholderChangePassword") %>" autocomplete="off" required>
@@ -391,9 +404,12 @@
<div class="form-group d-flex"> <div class="form-group d-flex">
<input type="password" name="confirmPassword" class="form-control rounded-left" placeholder="<%= localeMessage.getString("login.placeholderConfirmationPassword") %>" autocomplete="off" required> <input type="password" name="confirmPassword" class="form-control rounded-left" placeholder="<%= localeMessage.getString("login.placeholderConfirmationPassword") %>" autocomplete="off" required>
</div> </div>
<span style="font-size:0.8em; color:red">
※ 7글자 이상 & 영문/숫자/특수문자 2종류 이상
</span>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<input type="button" id ="changePwd" class="form-control btn btn-primary rounded submit px-3" value="변경"> <button type="submit" id="changePwd" class="form-control btn btn-primary rounded submit px-3">변경</button>
</div> </div>
</form> </form>
</div> </div>
+27 -5
View File
@@ -9,6 +9,8 @@
<%@page import="com.eactive.eai.rms.common.util.StringUtils" %> <%@page import="com.eactive.eai.rms.common.util.StringUtils" %>
<%@page import="com.eactive.eai.rms.common.datasource.DataSourceType" %> <%@page import="com.eactive.eai.rms.common.datasource.DataSourceType" %>
<%@page import="com.eactive.eai.rms.common.datasource.DataSourceTypeManager" %> <%@page import="com.eactive.eai.rms.common.datasource.DataSourceTypeManager" %>
<%@page import="com.eactive.eai.common.util.ApplicationContextProvider" %>
<%@page import="com.eactive.eai.rms.data.entity.man.role.RoleMenuAuthService" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %> <%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %>
<%@ include file="/jsp/common/include/localemessage.jsp" %> <%@ include file="/jsp/common/include/localemessage.jsp" %>
@@ -16,6 +18,12 @@
<% <%
String serviceKey = SessionManager.getServiceTypeKey(request); String serviceKey = SessionManager.getServiceTypeKey(request);
String serviceText = DataSourceTypeManager.getDataSourceType(serviceKey).getText(); String serviceText = DataSourceTypeManager.getDataSourceType(serviceKey).getText();
final String SITEMAP_MENU_ID = "0510013";
RoleMenuAuthService roleMenuAuthService = ApplicationContextProvider.getContext().getBean(RoleMenuAuthService.class);
String sitemapMenuAuth = roleMenuAuthService.getRoleList(SessionManager.getUserId(request), SITEMAP_MENU_ID, serviceKey);
boolean showSitemapLink = "R".equals(sitemapMenuAuth) || "W".equals(sitemapMenuAuth);
String roleScreenId = (String) request.getAttribute("roleScreenId"); String roleScreenId = (String) request.getAttribute("roleScreenId");
String mainPage = (String) request.getAttribute("mainPage"); String mainPage = (String) request.getAttribute("mainPage");
String menuId = (String) request.getAttribute("menuId"); String menuId = (String) request.getAttribute("menuId");
@@ -240,6 +248,12 @@
<link rel="stylesheet" type="text/css" media="screen" href="<c:url value="/css/web_ui.css"/>"/> <link rel="stylesheet" type="text/css" media="screen" href="<c:url value="/css/web_ui.css"/>"/>
<link rel="stylesheet" type="text/css" media="screen" href="<c:url value="/css/theme_${themeColor}.css"/>"/> <link rel="stylesheet" type="text/css" media="screen" href="<c:url value="/css/theme_${themeColor}.css"/>"/>
<style>
.topmenu_box .sitemap-link { display: inline-block; width: 80px; height: 80px; line-height: 80px; box-sizing: border-box; font-size: 14px; color: #666; text-align: center; opacity: 0.7; transition: all 0.3s; margin-top: 0px; }
.topmenu_box .sitemap-link:hover { opacity: 1; }
.topmenu_box .logout-link { line-height: 80px; margin-top:0; }
.topmenu_box > ul li:last-child > a:before { display: none; }
</style>
<script language="javascript" src="<c:url value="/js/jquery-1.12.1.min.js"/>"></script> <script language="javascript" src="<c:url value="/js/jquery-1.12.1.min.js"/>"></script>
<script language="javascript" src="<c:url value="/js/prefixfree.min.js"/>"></script> <script language="javascript" src="<c:url value="/js/prefixfree.min.js"/>"></script>
<script language="javascript" src="<c:url value="/js/jquery.cookie.js"/>"></script> <script language="javascript" src="<c:url value="/js/jquery.cookie.js"/>"></script>
@@ -529,6 +543,7 @@
console.log("selectLocaleValue : " + selectLocaleValue); console.log("selectLocaleValue : " + selectLocaleValue);
parent.changeLocale($(this).val()); parent.changeLocale($(this).val());
}); });
}); });
$(function() { $(function() {
@@ -551,21 +566,28 @@
</h1><!-- /monitoring/images/top_logo.png --> </h1><!-- /monitoring/images/top_logo.png -->
<div class="topmenu_box"> <div class="topmenu_box">
<ul> <ul>
<li style="width:240px;" id="newDashboardShow"> <li style="width:240px;">
<a style="width:240px;" <a style="width:240px; cursor: default"
href="#"><span><%=SessionManager.getUserName(request) %>(<%=SessionManager.getUserId(request) %>-<%=System.getProperty("inst.Name") %>)</span> href="#"><span><%=SessionManager.getUserName(request) %>(<%=SessionManager.getUserId(request) %>-<%=System.getProperty("inst.Name") %>)</span>
<span onClick="javascript:openColorPopup();"><%=localeMessage.getString("screen.customer") %></span> <span onClick="javascript:openColorPopup();"><%=localeMessage.getString("screen.customer") %></span>
<% if (!"".equals(LastLoginYms.trim())) { %> <% if (!"".equals(LastLoginYms.trim())) { %>
<span style="display:block;font-size:10px;"><%=localeMessage.getString("screen.lastLogin") %> : <%=LastLoginYms%> [ <%=LastLoginIp%> ]</span> <span style="display:block;font-size:10px;"><%=localeMessage.getString("screen.lastLogin") %> : <%=LastLoginYms%> [ <%=LastLoginIp%> ]</span>
<% } %> <% } %>
</a></li> </a></li>
<li> <li style="width:100px; line-height:80px; text-align:center;">
<select id="selectLocale" name="locale" style="margin-top: 20px; width:80px"> <select id="selectLocale" name="locale" style="width:60px; padding: 2px; text-align:center;">
<option value="en">English</option> <option value="en">English</option>
<option value="ko">한글</option> <option value="ko">한글</option>
</select> </select>
</li> </li>
<li onclick="logout()"><a href="#" class=""><img src="<c:url value="/img/icon_logout.png"/>" <% if (showSitemapLink) { %>
<li>
<a href="<c:url value="/common/acl/sitemap/sitemapMan.view"><c:param name="menuId" value="<%=SITEMAP_MENU_ID%>"/><c:param name="serviceType" value="<%=serviceKey%>"/></c:url>" target="mainFrame" class="sitemap-link">
<%= localeMessage.getString("screen.sitemap") %>
</a>
</li>
<% } %>
<li onclick="logout()"><a href="#" class="logout-link"><img src="<c:url value="/img/icon_logout.png"/>"
alt=""/><%= localeMessage.getString("screen.logout") %> alt=""/><%= localeMessage.getString("screen.logout") %>
</a></li> </a></li>
</ul> </ul>
@@ -82,8 +82,10 @@
apiDesc: api.eaiSvcDesc apiDesc: api.eaiSvcDesc
}; };
}); });
// addJSONData는 datatype:"local" 그리드의 p.data를 채우지 않아, 헤더 클릭 정렬 시 p.data(빈 배열) 기준으로 다시 그려지며 행이 사라짐.
// setGridParam({data:...}).trigger('reloadGrid')로 채워야 정렬이 정상 동작함.
$("#selectedApiGrid").jqGrid("clearGridData"); $("#selectedApiGrid").jqGrid("clearGridData");
$("#selectedApiGrid")[0].addJSONData(gridData); $("#selectedApiGrid").jqGrid('setGridParam', {data: gridData}).trigger('reloadGrid');
var data = json; var data = json;
$("#btn_gen_id").hide(); $("#btn_gen_id").hide();
@@ -133,7 +135,6 @@
var selectedApiGrid = $("#selectedApiGrid"); var selectedApiGrid = $("#selectedApiGrid");
selectedApiGrid.jqGrid({ selectedApiGrid.jqGrid({
datatype: "local", datatype: "local",
gridview: true, // 성능 향상
rownumbers: true, // 가상 인덱스 rownumbers: true, // 가상 인덱스
rownumWidth: 40, rownumWidth: 40,
colNames: ['API ID', 'API 이름'], colNames: ['API ID', 'API 이름'],
@@ -141,7 +142,7 @@
{name: 'apiId', index: 'apiId', width: 100, align: 'center'}, {name: 'apiId', index: 'apiId', width: 100, align: 'center'},
{name: 'apiDesc', index: 'apiDesc', width: 100, align: 'center'} {name: 'apiDesc', index: 'apiDesc', width: 100, align: 'center'}
], ],
rowNum: -1, rowNum: 9999,
// rowList: [10, 20, 30], // rowList: [10, 20, 30],
// pager: '#selectedApiGridPager', // pager: '#selectedApiGridPager',
// sortname: 'apiId', // sortname: 'apiId',
@@ -450,8 +451,8 @@
</div><!-- end content_top --> </div><!-- end content_top -->
<div class="content_middle"> <div class="content_middle">
<div class="search_wrap"> <div class="search_wrap">
<button type="button" class="cssbtn" id="btn_sync_portal" level="W" status="DETAIL"><i class="material-icons">sync</i> 개발자포탈 정보 반영 <!-- <button type="button" class="cssbtn" id="btn_sync_portal" level="W" status="DETAIL"><i class="material-icons">sync</i> 개발자포탈 정보 반영
</button> </button> -->
<button type="button" class="cssbtn" id="btn_delete" level="W" status="DETAIL"><i class="material-icons">delete</i> <%= localeMessage.getString("button.delete") %> <button type="button" class="cssbtn" id="btn_delete" level="W" status="DETAIL"><i class="material-icons">delete</i> <%= localeMessage.getString("button.delete") %>
</button> </button>
<button type="button" class="cssbtn" id="btn_modify" level="W" status="DETAIL,NEW"><i <button type="button" class="cssbtn" id="btn_modify" level="W" status="DETAIL,NEW"><i
@@ -19,7 +19,6 @@
var url = '<c:url value="/onl/admin/authserver/scopeMan.json" />'; var url = '<c:url value="/onl/admin/authserver/scopeMan.json" />';
var url_view = '<c:url value="/onl/admin/authserver/scopeMan.view" />'; var url_view = '<c:url value="/onl/admin/authserver/scopeMan.view" />';
var api_url_view = '<c:url value="/onl/apim/apigroup/apiGroupMan.view" />'; var api_url_view = '<c:url value="/onl/apim/apigroup/apiGroupMan.view" />';
var mapping_url = '<c:url value="/onl/admin/authserver/apiScopeMan.json" />';
var isDetail = false; var isDetail = false;
function isValid(){ function isValid(){
@@ -65,9 +64,9 @@ function mappingInfo(key){
$.ajax({ $.ajax({
type : "POST", type : "POST",
url: mapping_url, url: url,
dataType: "json", dataType: "json",
data: {cmd: 'LIST', searchScopeId : key}, data: {cmd: 'API_LIST', searchScopeId : key},
success: function(json){ success: function(json){
var mappingData = json.rows; var mappingData = json.rows;
@@ -131,45 +130,6 @@ function init(key, callback){
} }
} }
function saveApiGridData(scopeId, returnUrl) {
var postData = [];
var rows = $("#selectedApiGrid").jqGrid('getRowData');
$.each(rows, function(index, item) {
item.scopeId = scopeId;
});
postData.push({
name: "apiList",
value: JSON.stringify(rows)
});
postData.push({ name: "cmd" , value:"INSERT_APILIST" });
postData.push({ name: "scopeId" , value:scopeId });
$.ajax({
type : "POST",
url:mapping_url,
data: postData,
beforeSend: function() {
$("[id^='btn_']").prop("disabled", true);
$("#btn_modify").text("처리중 . . . . .");
},
success:function(args){
alert("저장 되었습니다.");
goNav(returnUrl);//LIST로 이동
},
error:function(e){
alert(e.responseText);
},
complete: function() {
$("[id^='btn_']").prop("disabled", false);
$("#btn_modify").text("수정");
}
});
}
$(document).ready(function() { $(document).ready(function() {
var returnUrl = getReturnUrlForReturn(); var returnUrl = getReturnUrlForReturn();
var key ="${param.scopeId}"; var key ="${param.scopeId}";
@@ -182,28 +142,39 @@ $(document).ready(function() {
if (!isValid()){ if (!isValid()){
return; return;
} }
var scopeId = $('input[name=scopeId]').val();
var postData = $('#ajaxForm').serializeArray(); var postData = $('#ajaxForm').serializeArray();
var rows = $("#selectedApiGrid").jqGrid('getRowData');
$.each(rows, function(index, item) {
item.scopeId = scopeId;
});
postData.push({ name: "apiList" , value: JSON.stringify(rows) });
if (isDetail){ if (isDetail){
postData.push({ name: "cmd" , value:"UPDATE"}); postData.push({ name: "cmd" , value:"UPDATE"});
}else{ }else{
postData.push({ name: "cmd" , value:"INSERT"}); postData.push({ name: "cmd" , value:"INSERT"});
} }
$.ajax({ $.ajax({
type : "POST", type : "POST",
url:url, url:url,
data:postData, data:postData,
beforeSend: function() {
$("[id^='btn_']").prop("disabled", true);
$("#btn_modify").text("처리중 . . . . .");
},
success:function(args){ success:function(args){
alert("SCOPE 정보가 저장 되었습니다. 이어서 API 리스트를 저장합니다."); alert("저장 되었습니다.");
goNav(returnUrl);//LIST로 이동
if(!isDetail) { // INSERT 일 경우 ScopeId를 업데이트
key = $('input[name=scopeId]').val();
}
saveApiGridData(key, returnUrl); // API LIST 저장.
}, },
error:function(e){ error:function(e){
alert(e.responseText); alert(e.responseText);
},
complete: function() {
$("[id^='btn_']").prop("disabled", false);
$("#btn_modify").text("수정");
} }
}); });
}); });
@@ -25,7 +25,7 @@ $(document).ready(function() {
datatype:"json", datatype:"json",
mtype: 'POST', mtype: 'POST',
url: url, url: url,
postData : { cmd : 'LIST', searchName: $('input[name=searchName]').val()}, postData : { cmd : 'LIST', searchName: $('input[name=searchName]').val(), searchUseYn: $('select[name=searchUseYn]').val()},
colNames:['<%= localeMessage.getString("infAdpConMan.adaNm") %>', colNames:['<%= localeMessage.getString("infAdpConMan.adaNm") %>',
'<%= localeMessage.getString("infAdpConMan.adaDes") %>', '<%= localeMessage.getString("infAdpConMan.adaDes") %>',
'<%= localeMessage.getString("infAdpConMan.thrPerSecond") %>', '<%= localeMessage.getString("infAdpConMan.thrPerSecond") %>',
@@ -62,6 +62,7 @@ $(document).ready(function() {
url2 += '&menuId='+'${param.menuId}'; url2 += '&menuId='+'${param.menuId}';
//검색값 //검색값
url2 += '&searchName='+$("input[name=searchName]").val(); url2 += '&searchName='+$("input[name=searchName]").val();
url2 += '&searchUseYn='+$("select[name=searchUseYn]").val();
//key값 //key값
url2 += '&name='+name; url2 += '&name='+name;
goNav(url2); goNav(url2);
@@ -80,7 +81,7 @@ $(document).ready(function() {
resizeJqGridWidth('grid','content_middle','1000'); resizeJqGridWidth('grid','content_middle','1000');
$("#btn_search").click(function(){ $("#btn_search").click(function(){
$("#grid").setGridParam({ postData: { searchName: $('input[name=searchName]').val()}, page:1 }).trigger("reloadGrid"); $("#grid").setGridParam({ postData: { searchName: $('input[name=searchName]').val(), searchUseYn: $('select[name=searchUseYn]').val()}, page:1 }).trigger("reloadGrid");
}); });
$("#btn_new").click(function(){ $("#btn_new").click(function(){
var url2 = url_view; var url2 = url_view;
@@ -126,6 +127,16 @@ $(document).ready(function() {
<tr> <tr>
<th style="width:180px;"><%= localeMessage.getString("infAdpConMan.adaNm") %></th> <th style="width:180px;"><%= localeMessage.getString("infAdpConMan.adaNm") %></th>
<td><input type="text" name="searchName" value="${param.searchName}"></td> <td><input type="text" name="searchName" value="${param.searchName}"></td>
<th style="width:180px;"><%= localeMessage.getString("infAdpConMan.useYn") %></th>
<td>
<div class="select-style">
<select name="searchUseYn">
<option value="">전체</option>
<option value="1">사용함</option>
<option value="0">사용안함</option>
</select>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -25,7 +25,7 @@ $(document).ready(function() {
datatype:"json", datatype:"json",
mtype: 'POST', mtype: 'POST',
url: url, url: url,
postData : { cmd : 'LIST', searchName: $('input[name=searchName]').val()}, postData : { cmd : 'LIST', searchName: $('input[name=searchName]').val(), searchUseYn: $('select[name=searchUseYn]').val()},
colNames:['클라이언트 ID', colNames:['클라이언트 ID',
'클라이언트명', '클라이언트명',
'<%= localeMessage.getString("infAdpConMan.thrPerSecond") %>', '<%= localeMessage.getString("infAdpConMan.thrPerSecond") %>',
@@ -62,6 +62,7 @@ $(document).ready(function() {
url2 += '&menuId='+'${param.menuId}'; url2 += '&menuId='+'${param.menuId}';
//검색값 //검색값
url2 += '&searchName='+$("input[name=searchName]").val(); url2 += '&searchName='+$("input[name=searchName]").val();
url2 += '&searchUseYn='+$("select[name=searchUseYn]").val();
//key값 //key값
url2 += '&name='+name; url2 += '&name='+name;
goNav(url2); goNav(url2);
@@ -80,7 +81,7 @@ $(document).ready(function() {
resizeJqGridWidth('grid','content_middle','1000'); resizeJqGridWidth('grid','content_middle','1000');
$("#btn_search").click(function(){ $("#btn_search").click(function(){
$("#grid").setGridParam({ postData: { searchName: $('input[name=searchName]').val()}, page:1 }).trigger("reloadGrid"); $("#grid").setGridParam({ postData: { searchName: $('input[name=searchName]').val(), searchUseYn: $('select[name=searchUseYn]').val()}, page:1 }).trigger("reloadGrid");
}); });
$("#btn_new").click(function(){ $("#btn_new").click(function(){
var url2 = url_view; var url2 = url_view;
@@ -90,6 +91,7 @@ $(document).ready(function() {
url2 += '&menuId='+'${param.menuId}'; url2 += '&menuId='+'${param.menuId}';
//검색값 //검색값
url2 += '&searchName='; url2 += '&searchName=';
url2 += '&searchUseYn='+$('select[name=searchUseYn]').val();
goNav(url2); goNav(url2);
}); });
@@ -124,6 +126,16 @@ $(document).ready(function() {
<tr> <tr>
<th style="width:180px;">클라이언트명</th> <th style="width:180px;">클라이언트명</th>
<td><input type="text" name="searchName" value="${param.searchName}"></td> <td><input type="text" name="searchName" value="${param.searchName}"></td>
<th style="width:180px;">사용여부</th>
<td>
<div class="select-style">
<select name="searchUseYn">
<option value="">전체</option>
<option value="1">사용함</option>
<option value="0">사용안함</option>
</select>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -298,6 +298,7 @@ $(document).ready(function() {
url: url, url: url,
data: postData, data: postData,
success: function(args) { success: function(args) {
console.log('mod', args);
showAlert("<%= localeMessage.getString("common.saveMsg") %>", { showAlert("<%= localeMessage.getString("common.saveMsg") %>", {
type: 'success', type: 'success',
title: '저장 완료', title: '저장 완료',
@@ -493,11 +494,6 @@ $(document).ready(function() {
<span class="bucket-summary-value" id="summaryThreshold">0<span class="unit">req</span></span> <span class="bucket-summary-value" id="summaryThreshold">0<span class="unit">req</span></span>
<span class="bucket-summary-sub" id="summaryThresholdCalc"></span> <span class="bucket-summary-sub" id="summaryThresholdCalc"></span>
</div> </div>
<div class="bucket-summary-item">
<span class="bucket-summary-label">적용 API</span>
<span class="bucket-summary-value" id="summaryActiveApi">0<span class="unit">개</span></span>
<span class="bucket-summary-sub" id="summaryActiveApiList"></span>
</div>
</div> </div>
</div> </div>
@@ -49,6 +49,7 @@ function groupNameFormat(cellvalue, options, rowObject) {
$(document).ready(function() { $(document).ready(function() {
$("input[name=searchStartYYYYMMDD],input[name=searchEndYYYYMMDD]").inputmask("yyyy-mm-dd",{'autoUnmask':true}); $("input[name=searchStartYYYYMMDD],input[name=searchEndYYYYMMDD]").inputmask("yyyy-mm-dd",{'autoUnmask':true});
$("input[name=searchStartTime],input[name=searchEndTime]").inputmask("hh:mm:ss",{'autoUnmask':true});
$("input[name=searchStartYYYYMMDD]").each(function(){ $("input[name=searchStartYYYYMMDD]").each(function(){
if ($(this).val() == undefined || $(this).val() == null || $(this).val() == ""){ if ($(this).val() == undefined || $(this).val() == null || $(this).val() == ""){
@@ -62,6 +63,18 @@ $(document).ready(function() {
} }
}); });
$("input[name=searchStartTime]").each(function(){
if ($(this).val() == undefined || $(this).val() == null || $(this).val() == ""){
$(this).val("000000");
}
});
$("input[name=searchEndTime]").each(function(){
if ($(this).val() == undefined || $(this).val() == null || $(this).val() == ""){
$(this).val("235959");
}
});
var gridPostData = getSearchForJqgrid("cmd","LIST"); //jqgrid에서는 object 로 var gridPostData = getSearchForJqgrid("cmd","LIST"); //jqgrid에서는 object 로
@@ -136,8 +149,10 @@ $(document).ready(function() {
var start = $("input[name=searchStartYYYYMMDD]").val().replace(/-/gi,""); var start = $("input[name=searchStartYYYYMMDD]").val().replace(/-/gi,"");
var end = $("input[name=searchEndYYYYMMDD]").val().replace(/-/gi,""); var end = $("input[name=searchEndYYYYMMDD]").val().replace(/-/gi,"");
var startTime = $("input[name=searchStartTime]").val();
var endTime = $("input[name=searchEndTime]").val();
if(start > end){ if((start + startTime) > (end + endTime)){
alert("조회기간을 확인해주세요."); alert("조회기간을 확인해주세요.");
return false; return false;
} }
@@ -197,14 +212,24 @@ $(document).ready(function() {
<tbody> <tbody>
<tr> <tr>
<th style="width:180px;"><%= localeMessage.getString("infConHstMan.date") %></th> <th style="width:180px;"><%= localeMessage.getString("infConHstMan.date") %></th>
<td> <td colspan="5">
<input type="text" name="searchStartYYYYMMDD" id="startDatepicker" readonly="readonly" value="" size="10" style="width:80px; border:1px solid #ebebec;"> <input type="text" name="searchStartYYYYMMDD" id="startDatepicker" readonly="readonly" value="" size="10" style="width:80px; border:1px solid #ebebec;">
<input type="text" name="searchStartTime" value="" size="8" style="width:70px; border:1px solid #ebebec;">
~ ~
<input type="text" name="searchEndYYYYMMDD" value="" id="endDatepicker" size="10" readonly="readonly" style="width:80px; border:1px solid #ebebec;"> <input type="text" name="searchEndYYYYMMDD" value="" id="endDatepicker" size="10" readonly="readonly" style="width:80px; border:1px solid #ebebec;">
<input type="text" name="searchEndTime" value="" size="8" style="width:70px; border:1px solid #ebebec;">
<input type="hidden" name="searchStartDate" value="" style="width:0px;"> <input type="hidden" name="searchStartDate" value="" style="width:0px;">
<input type="hidden" name="searchEndDate" value="" style="width:0px;"> <input type="hidden" name="searchEndDate" value="" style="width:0px;">
</td> </td>
</tr> </tr>
<tr>
<th style="width:180px;"><%= localeMessage.getString("infConHstMan.instNm") %></th>
<td><input type="text" name="searchInstanceName" value="${param.searchInstanceName}"></td>
<th style="width:180px;"><%= localeMessage.getString("infConHstMan.eaiSvcCd") %></th>
<td><input type="text" name="searchApiName" value="${param.searchApiName}"></td>
<th style="width:180px;"><%= localeMessage.getString("infConHstMan.adpGrpNm") %></th>
<td><input type="text" name="searchAdapterGroupName" value="${param.searchAdapterGroupName}"></td>
</tr>
</tbody> </tbody>
</table> </table>
</form> </form>
@@ -88,7 +88,7 @@ $(document).ready(function() {
datatype:"json", datatype:"json",
mtype: 'POST', mtype: 'POST',
url: url, url: url,
postData : { cmd : 'LIST', searchGroupName: $('input[name=searchGroupName]').val()}, postData : { cmd : 'LIST', searchGroupName: $('input[name=searchGroupName]').val(), searchUseYn: $('select[name=searchUseYn]').val()},
colNames:['그룹 ID', colNames:['그룹 ID',
'그룹명', '그룹명',
'초당 임계치', '초당 임계치',
@@ -125,6 +125,7 @@ $(document).ready(function() {
url2 += '&menuId='+'${param.menuId}'; url2 += '&menuId='+'${param.menuId}';
//검색값 //검색값
url2 += '&searchGroupName='+$("input[name=searchGroupName]").val(); url2 += '&searchGroupName='+$("input[name=searchGroupName]").val();
url2 += '&searchUseYn='+$("select[name=searchUseYn]").val();
//key값 //key값
url2 += '&groupId='+groupId; url2 += '&groupId='+groupId;
goNav(url2); goNav(url2);
@@ -143,7 +144,7 @@ $(document).ready(function() {
resizeJqGridWidth('grid','content_middle','1000'); resizeJqGridWidth('grid','content_middle','1000');
$("#btn_search").click(function(){ $("#btn_search").click(function(){
$("#grid").setGridParam({ postData: { searchGroupName: $('input[name=searchGroupName]').val()}, page:1 }).trigger("reloadGrid"); $("#grid").setGridParam({ postData: { searchGroupName: $('input[name=searchGroupName]').val(), searchUseYn: $('select[name=searchUseYn]').val()}, page:1 }).trigger("reloadGrid");
}); });
$("#btn_new").click(function(){ $("#btn_new").click(function(){
var url2 = url_view; var url2 = url_view;
@@ -188,6 +189,16 @@ $(document).ready(function() {
<tr> <tr>
<th style="width:180px;">그룹명</th> <th style="width:180px;">그룹명</th>
<td><input type="text" name="searchGroupName" value="${param.searchGroupName}"></td> <td><input type="text" name="searchGroupName" value="${param.searchGroupName}"></td>
<th style="width:180px;">사용여부</th>
<td>
<div class="select-style">
<select name="searchUseYn">
<option value="">전체</option>
<option value="1">사용함</option>
<option value="0">사용안함</option>
</select>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -25,7 +25,7 @@ $(document).ready(function() {
datatype:"json", datatype:"json",
mtype: 'POST', mtype: 'POST',
url: url, url: url,
postData : { cmd : 'LIST', searchName: $('input[name=searchName]').val()}, postData : { cmd : 'LIST', searchName: $('input[name=searchName]').val(), searchUseYn: $('select[name=searchUseYn]').val()},
colNames:['<%= localeMessage.getString("infIfConMan.ifNm") %>', colNames:['<%= localeMessage.getString("infIfConMan.ifNm") %>',
'<%= localeMessage.getString("infIfConMan.ifDesc") %>', '<%= localeMessage.getString("infIfConMan.ifDesc") %>',
'<%= localeMessage.getString("infAdpConMan.thrPerSecond") %>', '<%= localeMessage.getString("infAdpConMan.thrPerSecond") %>',
@@ -62,6 +62,7 @@ $(document).ready(function() {
url2 += '&menuId='+'${param.menuId}'; url2 += '&menuId='+'${param.menuId}';
//검색값 //검색값
url2 += '&searchName='+$("input[name=searchName]").val(); url2 += '&searchName='+$("input[name=searchName]").val();
url2 += '&searchUseYn='+$("select[name=searchUseYn]").val();
//key값 //key값
url2 += '&name='+name; url2 += '&name='+name;
goNav(url2); goNav(url2);
@@ -80,7 +81,7 @@ $(document).ready(function() {
resizeJqGridWidth('grid','content_middle','1000'); resizeJqGridWidth('grid','content_middle','1000');
$("#btn_search").click(function(){ $("#btn_search").click(function(){
$("#grid").setGridParam({ postData: { searchName: $('input[name=searchName]').val()}, page:1 }).trigger("reloadGrid"); $("#grid").setGridParam({ postData: { searchName: $('input[name=searchName]').val(), searchUseYn: $('select[name=searchUseYn]').val()}, page:1 }).trigger("reloadGrid");
}); });
$("#btn_new").click(function(){ $("#btn_new").click(function(){
var url2 = url_view; var url2 = url_view;
@@ -126,6 +127,16 @@ $(document).ready(function() {
<tr> <tr>
<th style="width:180px;"><%= localeMessage.getString("infIfConMan.ifNm") %></th> <th style="width:180px;"><%= localeMessage.getString("infIfConMan.ifNm") %></th>
<td><input type="text" name="searchName" value="${param.searchName}"></td> <td><input type="text" name="searchName" value="${param.searchName}"></td>
<th style="width:180px;"><%= localeMessage.getString("infAdpConMan.useYn") %></th>
<td>
<div class="select-style">
<select name="searchUseYn">
<option value="">전체</option>
<option value="1">사용함</option>
<option value="0">사용안함</option>
</select>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -71,18 +71,18 @@
$('#grid').children().remove(); $('#grid').children().remove();
const itemTypes = ['Field', 'Grid', 'Group', 'Attr']; const itemTypes = ['Field', 'Grid', 'Group', 'Attr', 'Array'];
columns = [ // for columnData prop columns = [ // for columnData prop
{ title: '<%= localeMessage.getString("layoutMan.itmeEn") %>', name: 'loutItemName', type: 'text', width:280, resize:true, minWidth:120 },
{ title: '<%= localeMessage.getString("layoutMan.itemDesc") %>', name: 'loutItemDesc', type: 'text', width:280, resize:true, minWidth:120 }, { title: '<%= localeMessage.getString("layoutMan.itemDesc") %>', name: 'loutItemDesc', type: 'text', width:280, resize:true, minWidth:120 },
{ title: '<%=localeMessage.getString("standardLayout.itemLevel")%>', name: 'loutItemDepth', type: 'text', width:50 }, { title: '<%= localeMessage.getString("layoutMan.itmeEn") %>', name: 'loutItemName', type: 'text', width:280, resize:true, minWidth:120 },
{ title: '<%=localeMessage.getString("standardLayout.itemLevel")%>', name: 'loutItemDepth', type: 'numeric', width:50 },
{ title: '<%= localeMessage.getString("standardLayout.itemType") %>', name: 'loutItemType', type: 'autocomplete', source: itemTypes, width:70 }, { title: '<%= localeMessage.getString("standardLayout.itemType") %>', name: 'loutItemType', type: 'autocomplete', source: itemTypes, width:70 },
{ title: '<%= localeMessage.getString("standardLayout.arraySize") %>', name: 'loutItemOccCnt', type: 'text', width:90 }, { title: '<%= localeMessage.getString("standardLayout.arraySize") %>', name: 'loutItemOccCnt', type: 'text', width:90 },
{ title: '<%= localeMessage.getString("standardLayout.refField") %>', name: 'loutItemOccRef', type: 'text', width:90 }, { title: '<%= localeMessage.getString("standardLayout.refField") %>', name: 'loutItemOccRef', type: 'text', width:90 },
{ title: '<%=localeMessage.getString("layoutMan.dataType")%>', name: 'loutItemDataType', type: 'autocomplete', source: basicDataTypes, width:160 }, { title: '<%=localeMessage.getString("layoutMan.dataType")%>', name: 'loutItemDataType', type: 'autocomplete', source: basicDataTypes, width:160 },
{ title: '<%=localeMessage.getString("layoutMan.dataLen")%>', name: 'loutItemLength', type: 'numeric', width: 70, align: 'center' }, { title: '<%=localeMessage.getString("layoutMan.dataLen")%>', name: 'loutItemLength', type: 'numeric', width: 70, align: 'center' },
{ title: '<%=localeMessage.getString("layoutMan.decpLen")%>', name: 'loutItemDecimal', type: 'text', width: 70 }, { title: '<%=localeMessage.getString("layoutMan.decpLen")%>', name: 'loutItemDecimal', type: 'numeric', width: 70 },
{ title: '<%=localeMessage.getString("layoutMan.defaultVal")%>', name: 'loutItemDefault', type: 'text', width: 70 }, { title: '<%=localeMessage.getString("layoutMan.defaultVal")%>', name: 'loutItemDefault', type: 'text', width: 70 },
{ title: '<%=localeMessage.getString("layoutMan.mask")%>', name: 'loutItemMaskYn', type: 'checkbox', width: 70 }, { title: '<%=localeMessage.getString("layoutMan.mask")%>', name: 'loutItemMaskYn', type: 'checkbox', width: 70 },
{ title: '<%=localeMessage.getString("layoutMan.moffset")%>', name: 'loutItemMaskOffset', type: 'text', width: 50 }, { title: '<%=localeMessage.getString("layoutMan.moffset")%>', name: 'loutItemMaskOffset', type: 'text', width: 50 },
@@ -182,6 +182,41 @@
$('#dataTotalLen').text(totalLen); $('#dataTotalLen').text(totalLen);
} }
function focusGridCell(colIndex, rowIndex) {
var cellName = jexcel.getColumnNameFromId([colIndex, rowIndex]);
var cell = grid.getCell(cellName);
grid.updateSelection(cell);
cell.scrollIntoView({block: 'center', inline: 'center'});
grid.openEditor(cell, false);
}
var INTEGER_REGEXP = /^(0|[1-9][0-9]*)$/;
function validateNumberColumns() {
var data = grid.getData();
var gridColumns = grid.options.columns;
for (var col = 0; col < gridColumns.length; col++) {
if (gridColumns[col].type !== 'numeric') {
continue;
}
for (var row = 0; row < data.length; row++) {
var value = data[row][col];
if (value == null) {
continue;
}
value = $.trim(String(value));
if (value !== '' && !INTEGER_REGEXP.test(value)) {
alert("숫자만 입력할 수 있습니다.");
focusGridCell(col, row);
return false;
}
}
}
return true;
}
function detail(key) { function detail(key) {
if (!isDetail) { if (!isDetail) {
return return
@@ -321,6 +356,10 @@
}); });
$("#btn_modify").click(function(){ $("#btn_modify").click(function(){
if (!validateNumberColumns()) {
return;
}
var postData = $('#ajaxForm').serializeArray(); var postData = $('#ajaxForm').serializeArray();
if (isDetail){ if (isDetail){
postData.push({ name: "cmd" , value:"UPDATE"}); postData.push({ name: "cmd" , value:"UPDATE"});
@@ -0,0 +1,258 @@
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<%@ page import="java.io.*"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
%>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
<jsp:include page="/jsp/common/include/css.jsp"/>
<jsp:include page="/jsp/common/include/script.jsp"/>
<script language="javascript" >
var url = '<c:url value="/onl/transaction/apim/apiInterfaceMan.json"/>';
var url_view = '<c:url value="/onl/transaction/apim/apiInterfaceMan.view"/>';
var selectName = "searchEaiBzwkDstcd"; // selectBox Name
function processSelectedData() {
var grid = $("#grid");
var selectedRowIds = grid.jqGrid('getGridParam', 'selarrrow');
var selectedData = [];
for(var i = 0; i < selectedRowIds.length; i++) {
var rowData = grid.jqGrid('getRowData', selectedRowIds[i]);
selectedData.push({
bizCode: rowData.eaiBzwkDstcd, // CLINET
apiId: rowData.eaiSvcName,
apiDesc: rowData.eaiSvcDesc, // SCOPE-API
APIFULLPATH: rowData.apiFullPath,
BZWKSVCKEYNAME: rowData.bzwksvckeyname
});
}
if(selectedData.length === 0) {
alert("선택된 항목이 없습니다.");
return;
}
alert("선택한 " + selectedData.length + "건의 API를 추가합니다.");
window.returnValue = selectedData.length === 1 ? selectedData[0] : selectedData;
window.close();
}
function updateSelectionInfo(gridId) {
var grid = $(gridId || "#grid");
var selectedRows = grid.jqGrid('getGridParam', 'selarrrow');
$("#selected-count").text("총: " + selectedRows.length + " 건");
}
function init( callback) {
$.ajax({
type : "POST",
url:url,
dataType:"json",
data:{cmd: 'LIST_COMBO'},
success:function(json){
new makeOptions("BIZCODE","BIZNAME").setObj($("select[name=searchEaiBzwkDstcd]")).setNoValueInclude(true).setNoValue("","전체").setData(json.bizList).setFormat(codeName3OptionFormat).rendering();
setSearchable(selectName); // 콤보에 searchable 설정
if (typeof callback === 'function') {
callback();
}
},
error:function(e){
alert(e.responseText);
}
});
}
function detail(){
var postData = getSearchForJqgrid("cmd","LIST"); //jqgrid에서는 object 로
$("#grid").setGridParam({ url:url,postData: postData }).trigger("reloadGrid");
}
function search(){
var postData = getSearchForJqgrid("cmd","LIST"); //jqgrid에서는 object 로
$("#grid").setGridParam({ url:url,postData: postData ,page:1 }).trigger("reloadGrid");
}
function ioFormatter(cellvalue,options,rowObject){
var serviceType = sessionStorage["serviceType"];
if(cellvalue == "I"){
return "타발";
}else if(cellvalue =="O"){
return "당발";
}
}
function adapterNameShortFormatter(cellvalue,options,rowObject){
if(cellvalue == null || cellvalue == '')
return ''
return cellvalue.substring(1, 4);
}
function list(){
detail()
var gridPostData = getSearchForJqgrid("cmd","LIST"); //jqgrid에서는 object 로
// gridPostData.searchApiEnableYn = 'Y';
var urlParams = new URLSearchParams(window.location.search);
var selectedApiIds = urlParams.get("selectedApiIds");
$('#grid').jqGrid({
datatype : "json",
mtype : 'POST',
url : url,
postData : gridPostData,
colNames : [ '업무구분',
'<%= localeMessage.getString("eaiMessage.eaiSvcName")%>',
'<%= localeMessage.getString("eaiMessage.eaiSvcDesc")%>',
'API FULL PATH',
'업무서비스명',
'요청',
'응답',
'작성자',
'가상응답여부',
'SyncAsyncType',
],
colModel : [ { name : 'eaiBzwkDstcd' , align : 'center' , width:'40', sortable:false},
{ name : 'eaiSvcName' , align : 'left' , width:'100'},
{ name : 'eaiSvcDesc' , align : 'left' },
{ name : 'apiFullPath' , align : 'left' , width:'100'},
{ name : 'bzwksvckeyname' , align : 'left' , width:'100', hidden: true},
{ name : 'fromAdapter' , align : 'center' , width:'40', formatter:adapterNameShortFormatter },
{ name : 'toAdapter' , align : 'center' , width:'40', formatter:adapterNameShortFormatter },
{ name : 'author' , align : 'center' , width:'60' },
{ name : 'simYn' , align : 'center' , width:'40' },
{ name : 'syncAsyncType' , align : 'center' , width:'40', hidden: true},
],
jsonReader : {
repeatitems : false
},
pager : $('#pager'),
page : '${param.page}',
rowNum : '${rmsDefaultRowNum}',
autoheight : true,
height : $("#container").height(),
autowidth : true,
viewrecords : true,
multiselect: false,
multiboxonly: false,
rowList : eval('[${rmsDefaultRowList}]'),
loadComplete:function (d){
var $grid = $(this);
if(selectedApiIds) {
const selectedIds = selectedApiIds.split(',');
$.each($grid.getDataIDs(), function(_, id) {
var rowData = $grid.getRowData(id);
if(selectedIds.includes(rowData.eaiSvcName)) {
$grid.jqGrid('setSelection', id, true);
}
});
}
var colModel = $(this).getGridParam("colModel");
for(var i = 0 ; i< colModel.length; i++){
$(this).setColProp(colModel[i].name, {sortable : false});
}
updateSelectionInfo("#grid");
},
onSelectRow: function(rowid, status, e) {
updateSelectionInfo("#grid");
},
onSelectAll: function(rowids, status) {
updateSelectionInfo("#grid");
},
ondblClickRow : function(rowId) {
const rowData = $(this).getRowData(rowId);
const returnValue = {
bizCode: rowData.eaiBzwkDstcd,
apiId: rowData.eaiSvcName,
apiDesc: rowData.eaiSvcDesc
};
//alert("선택한 API를 추가합니다.\nAPI ID: " + rowData.eaiSvcName);
window.returnValue = returnValue;
window.close();
}
});
}
$(document).ready(function() {
init(list);
resizeJqGridWidth('grid','content_middle','1000');
// 업무구분명 선택 시 자동 검색
$("select[name=searchEaiBzwkDstcd]").change(function() {
search();
});
$("#btn_search").click(function(){
search();
});
$("input[name^=search]").keydown(function(key){
if (key.keyCode == 13){
$("#btn_search").click();
}
});
buttonControl();
$("#btn_select").click(function(){
processSelectedData();
});
$("#btn_close").click(function () {
window.close();
});
});
</script>
</head>
<body>
<div>
<div class="content_middle" id="content_middle" style="margin-top: 0px">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_search" level="R" ><i class="material-icons">search</i> <%= localeMessage.getString("button.search") %></button>
<button type="button" class="cssbtn" id="btn_select" level="R" ><i class="material-icons">check</i> <%= localeMessage.getString("button.check") %></button>
<button type="button" class="cssbtn" id="btn_close" level="R" status="DETAIL"><i class="material-icons">close</i> <%= localeMessage.getString("button.close") %></button>
</div>
<div class="title" id="title" >${rmsMenuName}</div>
<!-- 선택 정보 표시 영역 추가 -->
<div style="margin-bottom: 5px;">
<span id="selected-count" style="color: #666; font-size: 12px;"></span>
</div>
<table class="search_condition" cellspacing=0;>
<tbody>
<tr>
<th style="width:180px;">업무구분명</th>
<td>
<select name="searchEaiBzwkDstcd" value="${param.searchEaiBzwkDstcd}">
</select>
</td>
<th style="width:180px;"><%= localeMessage.getString("eaiMessage.eaiSvcName")%></th>
<td>
<input type="text" name="searchEaiSvcName" value="${param.searchEaiSvcName}">
</td>
</tr>
<tr>
<th style="width:180px;"><%= localeMessage.getString("eaiMessage.eaiSvcDesc")%></th>
<td>
<input type="text" name="searchEaiSvcDesc" value="${param.searchEaiSvcDesc}">
</td>
<th style="width:180px;">API FULL PATH</th>
<td>
<input type="text" name="searchApiFullPath" value="${param.searchApiFullPath}">
</td>
</tr>
</tbody>
</table>
<table id="grid" ></table>
<div id="pager"></div>
</div><!-- end content_middle -->
</div><!-- end right_box -->
</body>
</html>
@@ -54,6 +54,7 @@
const LAYOUT_ITEM_TYPE_GROUP = "Group"; const LAYOUT_ITEM_TYPE_GROUP = "Group";
const LAYOUT_ITEM_TYPE_GRID = "Grid"; const LAYOUT_ITEM_TYPE_GRID = "Grid";
const LAYOUT_ITEM_TYPE_FIELD = "Field"; const LAYOUT_ITEM_TYPE_FIELD = "Field";
const LAYOUT_ITEM_TYPE_ARRAY = "Array";
var isDetail = false; var isDetail = false;
@@ -550,6 +551,14 @@
return data; return data;
} }
// 항목 자신이 Array인 경우, 경로/변환명령 끝에 반복 표시 [*]를 추가
function addArrayTag(loutItemType, data) {
if (loutItemType == LAYOUT_ITEM_TYPE_ARRAY && !data.endsWith("[*]")) {
data = data + "[*]";
}
return data;
}
//변환명에 있는 서비스코드와 if서비스 코드 매칭 //변환명에 있는 서비스코드와 if서비스 코드 매칭
function validation(){ function validation(){
if ($('input[name=cnvsnName]').val() == '') { if ($('input[name=cnvsnName]').val() == '') {
@@ -889,10 +898,10 @@
let selectValue = ""; let selectValue = "";
let targetValue = ""; let targetValue = "";
for ( var i = 0; i < sourceData.length; i++) { for ( var i = 0; i < sourceData.length; i++) {
if (sourceData[i]['loutItemType'] != "Field" && sourceData[i]['loutItemType'] != "Attr") continue; if (sourceData[i]['loutItemType'] != "Field" && sourceData[i]['loutItemType'] != "Attr" && sourceData[i]['loutItemType'] != "Array") continue;
selectValue = (sourceData[i]['LOUTITEMPATH']).substr(sourceData[i]['loutName'].length+1); selectValue = (sourceData[i]['LOUTITEMPATH']).substr(sourceData[i]['loutName'].length+1);
for (var j = 0; j <targetData.length; j++) { for (var j = 0; j <targetData.length; j++) {
if (targetData[j]['loutItemType'] != "Field" && targetData[j]['loutItemType'] != "Attr") continue; if (targetData[j]['loutItemType'] != "Field" && targetData[j]['loutItemType'] != "Attr" && targetData[j]['loutItemType'] != "Array") continue;
targetValue = (targetData[j]['LOUTITEMPATH']).substr(targetData[j]['loutName'].length+1); targetValue = (targetData[j]['LOUTITEMPATH']).substr(targetData[j]['loutName'].length+1);
if (targetValue == selectValue ) { if (targetValue == selectValue ) {
var data = sourceData[i]['LOUTITEMPATH']; var data = sourceData[i]['LOUTITEMPATH'];
@@ -916,8 +925,8 @@
//field, ATTR 여부 판단 //field, ATTR 여부 판단
var sourceRow = sourceGrid.getRowData( srcRowId ); var sourceRow = sourceGrid.getRowData( srcRowId );
var targetRow = targetGrid.getRowData( tgtRowId ); var targetRow = targetGrid.getRowData( tgtRowId );
if ( (sourceRow["loutItemType"] == 'Field' || sourceRow["loutItemType"] == 'Attr') && if ( (sourceRow["loutItemType"] == 'Field' || sourceRow["loutItemType"] == 'Attr' || sourceRow["loutItemType"] == 'Array') &&
(targetRow["loutItemType"] == 'Field' || targetRow["loutItemType"] == 'Attr') ){ (targetRow["loutItemType"] == 'Field' || targetRow["loutItemType"] == 'Attr' || targetRow["loutItemType"] == 'Array') ){
; ;
}else{ }else{
alert('<%=localeMessage.getString("transformManDetail.alert3")%>'); alert('<%=localeMessage.getString("transformManDetail.alert3")%>');
@@ -936,15 +945,15 @@
var targetValue = ""; var targetValue = "";
var j=tgtIndex-1-1; var j=tgtIndex-1-1;
for ( var i = srcIndex-1; i < sourceData.length; i++) { for ( var i = srcIndex-1; i < sourceData.length; i++) {
if (sourceData[i]['loutItemType'] != LAYOUT_ITEM_TYPE_FIELD) continue; if (sourceData[i]['loutItemType'] != LAYOUT_ITEM_TYPE_FIELD && sourceData[i]['loutItemType'] != LAYOUT_ITEM_TYPE_ARRAY) continue;
j++; j++;
while (targetData.length -1 >= j &&targetData[j]["loutItemType"] != LAYOUT_ITEM_TYPE_FIELD){ while (targetData.length -1 >= j && targetData[j]["loutItemType"] != LAYOUT_ITEM_TYPE_FIELD && targetData[j]["loutItemType"] != LAYOUT_ITEM_TYPE_ARRAY){
j++; j++;
} }
if (targetData.length -1 < j) break; if (targetData.length -1 < j) break;
var targetRowId = targetData[j]['id']; var targetRowId = targetData[j]['id'];
targetGrid.jqGrid('setCell',targetRowId,'CNVSNCMDNAME',addGroupTag(srcGroup,sourceData[i]['LOUTITEMPATH'])); targetGrid.jqGrid('setCell',targetRowId,'CNVSNCMDNAME',addArrayTag(sourceData[i]['loutItemType'], addGroupTag(srcGroup,sourceData[i]['LOUTITEMPATH'])));
targetGrid.jqGrid('setCell',targetRowId,'CNVSNRSULTITEMPATHNAME',addGroupTag(tgtGroup,targetData[j]['LOUTITEMPATH'])); targetGrid.jqGrid('setCell',targetRowId,'CNVSNRSULTITEMPATHNAME',addArrayTag(targetData[j]['loutItemType'], addGroupTag(tgtGroup,targetData[j]['LOUTITEMPATH'])));
targetGrid.jqGrid('setCell',targetRowId,'CNVSNITEMSERNO',targetData[j]['loutItemSerno']); targetGrid.jqGrid('setCell',targetRowId,'CNVSNITEMSERNO',targetData[j]['loutItemSerno']);
const sourceEndpointElementId = sourceGrid.getEndpointElementId(sourceData[i]['id']); const sourceEndpointElementId = sourceGrid.getEndpointElementId(sourceData[i]['id']);
@@ -1015,7 +1024,7 @@
var targetData = targetGrid.getRowData(); var targetData = targetGrid.getRowData();
var gridData = new Array(); var gridData = new Array();
for (var i = 0; i <targetData.length; i++) { for (var i = 0; i <targetData.length; i++) {
if ((targetData[i]['loutItemType'] != "Field") && (targetData[i]['loutItemType'] != "Attr")) { if ((targetData[i]['loutItemType'] != "Field") && (targetData[i]['loutItemType'] != "Attr") && (targetData[i]['loutItemType'] != "Array")) {
continue; continue;
} }
if (targetData[i]['CNVSNCMDNAME']==null if (targetData[i]['CNVSNCMDNAME']==null
@@ -1158,7 +1167,7 @@
function srcformatterFunction(cellvalue,options,rowObject){ function srcformatterFunction(cellvalue,options,rowObject){
var rowId = options["rowId"]; var rowId = options["rowId"];
var loutItemType = rowObject["loutItemType"]; var loutItemType = rowObject["loutItemType"];
if (loutItemType == 'Field' || loutItemType == 'Attr'){ if (loutItemType == 'Field' || loutItemType == 'Attr' || loutItemType == 'Array'){
return "<div id='div_src_"+rowId+"' name='src_"+rowId+"' style='width:28px;height:17px;background-color: #D12F35; border-color: #ab1717; border-style: solid; border-width: 1px; border-radius: 3px;' />"; return "<div id='div_src_"+rowId+"' name='src_"+rowId+"' style='width:28px;height:17px;background-color: #D12F35; border-color: #ab1717; border-style: solid; border-width: 1px; border-radius: 3px;' />";
}else{ }else{
return "<div id='div_srcgroup_"+rowId+"' name='srcgroup_"+rowId+"' style='width:28px;height:17px;' />"; return "<div id='div_srcgroup_"+rowId+"' name='srcgroup_"+rowId+"' style='width:28px;height:17px;' />";
@@ -1170,7 +1179,7 @@
function tgtformatterFunction(cellvalue,options,rowObject){ function tgtformatterFunction(cellvalue,options,rowObject){
var rowId = options["rowId"]; var rowId = options["rowId"];
var loutItemType = rowObject["loutItemType"]; var loutItemType = rowObject["loutItemType"];
if (loutItemType == 'Field' || loutItemType == 'Attr'){ if (loutItemType == 'Field' || loutItemType == 'Attr' || loutItemType == 'Array'){
return "<div id='div_tgt_"+rowId+"_base' name='div_tgt_"+rowId+"_base' style='width:30px;height:20px; background-color:gray;' >" return "<div id='div_tgt_"+rowId+"_base' name='div_tgt_"+rowId+"_base' style='width:30px;height:20px; background-color:gray;' >"
+ "<div id='div_tgt_"+rowId+"' name='tgt_"+rowId+"' style='width:28px;height:17px; background-color: #3E7E9C; border-color: #217ca7; border-style: solid; border-width: 1px; border-radius: 3px;' onclick='jqGridOnTargetGridEndpointClick(this)'>" + "<div id='div_tgt_"+rowId+"' name='tgt_"+rowId+"' style='width:28px;height:17px; background-color: #3E7E9C; border-color: #217ca7; border-style: solid; border-width: 1px; border-radius: 3px;' onclick='jqGridOnTargetGridEndpointClick(this)'>"
+ "</div></div>"; + "</div></div>";
@@ -1669,8 +1678,8 @@
return; return;
} }
if ((sourceRow["loutItemType"] != 'Field' && sourceRow["loutItemType"] != 'Attr') if ((sourceRow["loutItemType"] != 'Field' && sourceRow["loutItemType"] != 'Attr' && sourceRow["loutItemType"] != 'Array')
|| (targetRow["loutItemType"] != 'Field' && targetRow["loutItemType"] != 'Attr')) { || (targetRow["loutItemType"] != 'Field' && targetRow["loutItemType"] != 'Attr' && targetRow["loutItemType"] != 'Array')) {
alert('<%=localeMessage.getString("transformManDetail.alert3")%>'); alert('<%=localeMessage.getString("transformManDetail.alert3")%>');
return ; return ;
} }
@@ -1684,7 +1693,7 @@
if (targetRow['CNVSNCMDNAME'] == '') { if (targetRow['CNVSNCMDNAME'] == '') {
// 변환 명령 값이 한번 설정 되면 변경 되지 않게 함. // 변환 명령 값이 한번 설정 되면 변경 되지 않게 함.
var cnvsnCmdName = addGroupTag(srcGroup,sourceRow["LOUTITEMPATH"]); var cnvsnCmdName = addArrayTag(sourceRow["loutItemType"], addGroupTag(srcGroup,sourceRow["LOUTITEMPATH"]));
// function까지 처리되도록 수정했으나, parameter가 하나인 경우에만 정상처리됨 // function까지 처리되도록 수정했으나, parameter가 하나인 경우에만 정상처리됨
if($('#functionCombo').val() != "") { if($('#functionCombo').val() != "") {
cnvsnCmdName = $('#functionCombo').val() + "(" + cnvsnCmdName + ")"; cnvsnCmdName = $('#functionCombo').val() + "(" + cnvsnCmdName + ")";
@@ -1693,7 +1702,7 @@
targetGrid.jqGrid('setCell',tgtRowId,'CNVSNCMDNAME', cnvsnCmdName); targetGrid.jqGrid('setCell',tgtRowId,'CNVSNCMDNAME', cnvsnCmdName);
} }
targetGrid.jqGrid('setCell',tgtRowId,'CNVSNRSULTITEMPATHNAME',addGroupTag(tgtGroup,targetRow["LOUTITEMPATH"])); targetGrid.jqGrid('setCell',tgtRowId,'CNVSNRSULTITEMPATHNAME',addArrayTag(targetRow["loutItemType"], addGroupTag(tgtGroup,targetRow["LOUTITEMPATH"])));
targetGrid.jqGrid('setCell',tgtRowId,'CNVSNITEMSERNO',targetRow['loutItemSerno']); targetGrid.jqGrid('setCell',tgtRowId,'CNVSNITEMSERNO',targetRow['loutItemSerno']);
targetGrid.jqGrid('setCell',tgtRowId,'CNVSNSRCITEMS', cnvsnSrcItems); targetGrid.jqGrid('setCell',tgtRowId,'CNVSNSRCITEMS', cnvsnSrcItems);
targetGrid.jqGrid('setCell',tgtRowId,'ISVALIDMAPPING', true); targetGrid.jqGrid('setCell',tgtRowId,'ISVALIDMAPPING', true);
@@ -1885,8 +1894,8 @@
if (loutItemType == LAYOUT_ITEM_TYPE_GRID || loutItemType == LAYOUT_ITEM_TYPE_GROUP) { if (loutItemType == LAYOUT_ITEM_TYPE_GRID || loutItemType == LAYOUT_ITEM_TYPE_GROUP) {
// group 인 경우 // group 인 경우
items = createGridGroupContextMenuItems(grid, rowId); items = createGridGroupContextMenuItems(grid, rowId);
} else if(loutItemType == LAYOUT_ITEM_TYPE_FIELD) { } else if(loutItemType == LAYOUT_ITEM_TYPE_FIELD || loutItemType == LAYOUT_ITEM_TYPE_ARRAY) {
// field 인 경우 // field, array 인 경우
items = createFieldContextMenuItems(grid, positionElement.attr('id')); items = createFieldContextMenuItems(grid, positionElement.attr('id'));
} else { } else {
return false; return false;
@@ -1938,8 +1947,8 @@
var key = ""; var key = "";
var args = new Object(); var args = new Object();
args['eaiSvcName'] = $('input[name=eaiSvcName]').val(); args['eaiSvcName'] = $('input[name=eaiSvcName]').val();
var url='<c:url value="/onl/transaction/extnl/interfaceMan.view"/>'; var url=url_view; //'<c:url value="/onl/transaction/extnl/interfaceMan.view"/>';
url = url + "?cmd=POPUP"; url = url + "?cmd=API_POPUP";
var ret = showModal(url,args,1020,630, function(arg){ var ret = showModal(url,args,1020,630, function(arg){
var args = null; var args = null;
if(arg == null || arg == undefined ) {//chrome if(arg == null || arg == undefined ) {//chrome
@@ -1953,11 +1962,11 @@
var ret = args.returnValue; var ret = args.returnValue;
console.log("ret",ret); console.log("ret",ret);
key = ret['key']; key = ret['apiId'];
$("input[name=eaiSvcName]").val(key); $("input[name=eaiSvcName]").val(key);
$("input[name=eaiSvcDesc]").val(ret['eaiSvcDesc']); $("input[name=eaiSvcDesc]").val(ret['apiDesc']);
bzwkDstCd = ret['eaiBzwkDstCd']; bzwkDstCd = ret['bizCode'];
$("input[name=eaiSvcName]").change(); $("input[name=eaiSvcName]").change();
}); });
@@ -1989,7 +1998,7 @@
break; break;
} }
if (loutItemType == LAYOUT_ITEM_TYPE_FIELD) { if (loutItemType == LAYOUT_ITEM_TYPE_FIELD || loutItemType == LAYOUT_ITEM_TYPE_ARRAY) {
loutItemPath = loutItemPath.replace(new RegExp("^" + prefix), ""); loutItemPath = loutItemPath.replace(new RegExp("^" + prefix), "");
console.debug(grid.getId() + " : loutItemPath : " + loutItemPath); console.debug(grid.getId() + " : loutItemPath : " + loutItemPath);
rowData['GROUP_LOUTITEMPATH'] = loutItemPath; rowData['GROUP_LOUTITEMPATH'] = loutItemPath;
@@ -20,6 +20,7 @@
<jsp:include page="/jsp/common/include/script.jsp"/> <jsp:include page="/jsp/common/include/script.jsp"/>
<script language="javascript" > <script language="javascript" >
var url_view = '<c:url value="/onl/admin/rule/transform2/transform2Man.view" />';
var $ = jQuery.noConflict(); var $ = jQuery.noConflict();
var bzwkDstCd = ""; var bzwkDstCd = "";
@@ -81,8 +82,9 @@ $(document).ready(function() {
var key = ""; var key = "";
var args = new Object(); var args = new Object();
args['eaiSvcName'] = $('input[name=eaiSvcName]').val(); args['eaiSvcName'] = $('input[name=eaiSvcName]').val();
var url='<c:url value="/onl/transaction/extnl/interfaceMan.view"/>'; var url=url_view; //'<c:url value="/onl/transaction/extnl/interfaceMan.view"/>';
url = url + "?cmd=POPUP"; url = url + "?cmd=API_POPUP";
console.log('[new]', url);
var ret = showModal(url,args,1020,630, function(arg){ var ret = showModal(url,args,1020,630, function(arg){
var args = null; var args = null;
if(arg == null || arg == undefined ) {//chrome if(arg == null || arg == undefined ) {//chrome
@@ -96,11 +98,11 @@ $(document).ready(function() {
var ret = args.returnValue; var ret = args.returnValue;
console.log("ret",ret); console.log("ret",ret);
key = ret['key']; key = ret['apiId'];
$("input[name=eaiSvcName]").val(key); $("input[name=eaiSvcName]").val(key);
$("input[name=eaiSvcDesc]").val(ret['eaiSvcDesc']); $("input[name=eaiSvcDesc]").val(ret['apiDesc']);
bzwkDstCd = ret['eaiBzwkDstCd']; bzwkDstCd = ret['bizCode'];
$("input[name=eaiSvcName]").change(); $("input[name=eaiSvcName]").change();
//$("select[name=eaiSevrDstcd]").val(ret['eaiSevrDstcd']); //$("select[name=eaiSevrDstcd]").val(ret['eaiSevrDstcd']);
@@ -415,6 +415,12 @@
<input type="text" name="groupName" data-required data-warning="API 그룹 명을 입력하여 주십시오."> <input type="text" name="groupName" data-required data-warning="API 그룹 명을 입력하여 주십시오.">
</td> </td>
</tr> </tr>
<tr>
<th style="width:150px;">그룹 설명</th>
<td colspan="7">
<input type="text" name="groupDesc">
</td>
</tr>
<tr> <tr>
<th style="width:150px;">공개 여부</th> <th style="width:150px;">공개 여부</th>
<td style="width:100px;"> <td style="width:100px;">
@@ -461,14 +467,6 @@
<img id="mainIconPreview" src="#" alt="Main Icon Preview" style="display:none;height:20px"> <img id="mainIconPreview" src="#" alt="Main Icon Preview" style="display:none;height:20px">
</td> </td>
</tr> </tr>
<%--<tr>
<th>컨텐츠 내용</th>
<td colspan="5">
<textarea name="contentDetail" id="contentDetail" rows="10" cols="50"></textarea>
</td>
</tr>--%>
</table> </table>
<div style="margin-top:15px;"> <div style="margin-top:15px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px "> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px ">
@@ -56,11 +56,16 @@
$("#expectEndDate").datepicker().inputmask("yyyy-mm-dd", {'autoUnmask': true}); $("#expectEndDate").datepicker().inputmask("yyyy-mm-dd", {'autoUnmask': true});
$("#btn_app_approve").click(function () { $("#btn_app_approve").click(function () {
if (confirm("승인하시겠습니까?")) { var isDeleteReq = (currentRequestType === 'DELETE');
var gwAction = (isDeleteReq && $('#chkGwDelete').is(':checked')) ? 'DELETE' : 'BLOCK';
var confirmMsg = isDeleteReq
? "해지 승인 시 포털 인증키(Credential)는 삭제되며,\n게이트웨이 클라이언트는 [" + (gwAction === 'DELETE' ? "완전 삭제" : "차단(비활성화)") + "] 처리됩니다.\n승인하시겠습니까?"
: "승인하시겠습니까?";
if (confirm(confirmMsg)) {
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: url, url: url,
data: {cmd: "APPROVE", id: key}, data: {cmd: "APPROVE", id: key, gwAction: gwAction},
success: function (args) { success: function (args) {
<%--alert("<%= localeMessage.getString("approval.approveSuccess") %>");--%> <%--alert("<%= localeMessage.getString("approval.approveSuccess") %>");--%>
alert("승인되었습니다."); alert("승인되었습니다.");
@@ -101,6 +106,32 @@
} }
}); });
$("#btn_app_force_reject").click(function () {
if (!confirm("승인자 목록에 삭제된 사용자가 있어 승인 진행이 불가능한 건입니다.\n강제 반려하시겠습니까?")) {
return;
}
var rejectReason = prompt("강제 반려 사유를 입력해 주세요");
if (rejectReason != null) {
$.ajax({
type: "POST",
url: url,
data: {cmd: "FORCE_REJECT", id: key, rejectReason: rejectReason},
success: function (args) {
alert("강제 반려되었습니다.");
if (window.dialogArguments && window.dialogArguments.self) {
window.dialogArguments.self.location.reload();
} else if (window.opener) {
window.opener.location.reload();
}
window.close();
},
error: function (e) {
alert(e.responseText);
}
});
}
});
$('#btn_app_redeploy').click(function () { $('#btn_app_redeploy').click(function () {
if (confirm("재배포 하시겠습니까?")) { if (confirm("재배포 하시겠습니까?")) {
$.ajax({ $.ajax({
@@ -150,6 +181,8 @@
}); });
}); });
var currentRequestType = null; // 현재 신청 유형 (DELETE 승인 시 GW 처리방식 선택 노출용)
function detail(key) { function detail(key) {
$.ajax({ $.ajax({
type: "POST", type: "POST",
@@ -178,10 +211,11 @@
$("#createdDate").text(data.createdDate).inputmask("9999-99-99 99:99:99.999", {'autoUnmask': true}); $("#createdDate").text(data.createdDate).inputmask("9999-99-99 99:99:99.999", {'autoUnmask': true});
$("#expectEndDate").val(data.expectEndDate); $("#expectEndDate").val(data.expectEndDate);
if (data.approvers.some(approver => var isCurrentApprover = data.approvers.some(approver =>
approver.approverStatus === 'CURRENT' && approver.approverStatus === 'CURRENT' &&
approver.user.USERID === loginUser approver.user && approver.user.USERID === loginUser
)) { );
if (isCurrentApprover) {
document.getElementById("btn_app_approve").style.display = ""; document.getElementById("btn_app_approve").style.display = "";
document.getElementById("btn_app_reject").style.display = ""; document.getElementById("btn_app_reject").style.display = "";
} else { } else {
@@ -189,6 +223,29 @@
document.getElementById("btn_app_reject").style.display = "none"; document.getElementById("btn_app_reject").style.display = "none";
} }
// API 이용해지(DELETE) 건: 승인 가능 상태의 현재 승인자에게만 GW 처리방식 선택 노출
currentRequestType = data.requestType;
var inProgress = data.approvalStatus.code === 'REQUESTED' || data.approvalStatus.code === 'PROCESSING';
if (data.requestType === 'DELETE' && isCurrentApprover && inProgress) {
$("#gwActionSection").show();
} else {
$("#gwActionSection").hide();
}
// 처리 완료된 해지 건은 어떤 방식으로 실행됐는지 표시
if (data.requestType === 'DELETE' && data.gwAction) {
$("#gwActionResult").text("GW 처리방식: " + (data.gwAction === 'DELETE' ? "완전 삭제" : "차단(비활성화)")).show();
} else {
$("#gwActionResult").hide();
}
// 삭제된 승인자(USERID 없음)로 진행 불가한 건: 요청됨/진행중 상태면 승인자가 아니어도 강제 반려 허용
var hasDeletedApprover = data.approvers.some(approver =>
!approver.user || !approver.user.USERID
);
var isProgressStatus = data.approvalStatus.code === 'REQUESTED' || data.approvalStatus.code === 'PROCESSING';
document.getElementById("btn_app_force_reject").style.display =
(hasDeletedApprover && isProgressStatus && !isCurrentApprover) ? "" : "none";
if (data.approvalStatus.code === 'FAILED') { if (data.approvalStatus.code === 'FAILED') {
document.getElementById("btn_app_redeploy").style.display = ""; document.getElementById("btn_app_redeploy").style.display = "";
} }
@@ -335,6 +392,13 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="gwActionSection" style="display:none; margin: 12px 0 0 4px; color: #c0392b;">
<label style="cursor:pointer;">
<input type="checkbox" id="chkGwDelete"/>
GW 인증정보 완전 삭제 (미체크 시 차단 처리 &mdash; 인증정보 보존, 복구 가능)
</label>
</div>
<div id="gwActionResult" style="display:none; margin: 12px 0 0 4px; font-weight: bold;"></div>
<div class="popup_button"> <div class="popup_button">
<button type="button" class="cssbtn" id="btn_modify" level="W"><i <button type="button" class="cssbtn" id="btn_modify" level="W"><i
class="material-icons">save</i>수정 class="material-icons">save</i>수정
@@ -345,6 +409,9 @@
<button type="button" class="cssbtn" style="display: none;" id="btn_app_reject" level="W"><i <button type="button" class="cssbtn" style="display: none;" id="btn_app_reject" level="W"><i
class="material-icons">close</i>반려 class="material-icons">close</i>반려
</button> </button>
<button type="button" class="cssbtn" style="display: none;" id="btn_app_force_reject" level="W"><i
class="material-icons">block</i>강제 반려
</button>
<button type="button" class="cssbtn" style="display: none;" id="btn_app_redeploy" level="W"><i <button type="button" class="cssbtn" style="display: none;" id="btn_app_redeploy" level="W"><i
class="material-icons">close</i>재처리 class="material-icons">close</i>재처리
</button> </button>
@@ -131,6 +131,24 @@
// var reverseNumber = records - ((page - 1) * rowNum + i); // var reverseNumber = records - ((page - 1) * rowNum + i);
$(this).setCell(rows[i], 'rowNum', number); $(this).setCell(rows[i], 'rowNum', number);
} }
// 승인 상태별 행 배경색: 요청됨(#fff3cd) / 진행중(#d1ecf1) 강조
if (d && d.rows) {
for (var j = 0; j < d.rows.length; j++) {
var st = d.rows[j].approvalStatus || {};
var code = st.code || '';
var desc = st.description || '';
var bg = '';
if (code === 'REQUESTED' || desc === '요청됨') {
bg = '#fff3cd';
} else if (code === 'PROCESSING' || desc === '진행중') {
bg = '#d1ecf1';
}
if (bg) {
$('#grid tr#' + $.jgrid.jqID(d.rows[j].id)).css('background-color', bg);
}
}
}
}, },
loadError: function(jqXHR, textStatus, errorThrown) { loadError: function(jqXHR, textStatus, errorThrown) {
var location = '<%=request.getContextPath()%>/'; var location = '<%=request.getContextPath()%>/';
@@ -176,7 +194,7 @@
<div class="title">승인 요청 목록<span class="tooltip">승인 요청을 관리합니다.</span></div> <div class="title">승인 요청 목록<span class="tooltip">승인 요청을 관리합니다.</span></div>
<c:if test="${hardDeleteEnabled}"> <c:if test="${hardDeleteEnabled}">
<div style="background:#fff3cd; border:1px solid #ffc107; padding:8px 15px; margin:5px 0; border-radius:4px; color:#856404;"> <div style="background:#fff3cd; border:1px solid #ffc107; padding:8px 15px; margin:5px 0; border-radius:4px; color:#856404;">
<strong>[주의]</strong> 선택 삭제 시 승인 요청과 승인자 이력이 DB에서 영구 삭제됩니다. <strong>[주의]</strong> 삭제 기능은 <strong>개발(dev) 환경에서만</strong> 동작합니다. 선택 삭제 시 승인 요청과 승인자 이력이 DB에서 영구 삭제됩니다.
</div> </div>
</c:if> </c:if>
<form id="ajaxForm" onsubmit="return false;"> <form id="ajaxForm" onsubmit="return false;">
@@ -45,6 +45,10 @@
padding: 2px 5px; padding: 2px 5px;
border-radius: 3px; border-radius: 3px;
} }
.cssbtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style> </style>
<script language="javascript"> <script language="javascript">
<% <%
@@ -150,6 +154,55 @@
} }
}); });
$("#btn_user_force_reject").click(function() {
if (!confirm("승인자 목록에 삭제된 사용자가 있어 승인 진행이 불가능한 건입니다.\n강제 반려하시겠습니까?")) {
return;
}
var rejectReason = prompt("강제 반려 사유를 입력해 주세요");
if (rejectReason != null) {
$.ajax({
type: "POST",
url: url,
data: {cmd: "FORCE_REJECT", id: key, rejectReason: rejectReason},
success: function(args) {
alert("강제 반려되었습니다.");
if (window.dialogArguments && window.dialogArguments.self) {
window.dialogArguments.self.location.reload();
} else if (window.opener) {
window.opener.location.reload();
}
window.close();
},
error: function(e) {
alert(e.responseText);
}
});
}
});
$("#btn_user_delete").click(function() {
if (!confirm("삭제된 사용자 또는 법인이 포함되어 승인·반려할 수 없는 요청입니다.\n이 승인 요청을 삭제하시겠습니까?")) {
return;
}
$.ajax({
type: "POST",
url: url,
data: {cmd: "DELETE", id: key},
success: function(args) {
alert("삭제되었습니다.");
if (window.dialogArguments && window.dialogArguments.self) {
window.dialogArguments.self.location.reload();
} else if (window.opener) {
window.opener.location.reload();
}
window.close();
},
error: function(e) {
alert(e.responseText);
}
});
});
$('#btn_user_redeploy').click(function() { $('#btn_user_redeploy').click(function() {
if (confirm("재배포 하시겠습니까?")) { if (confirm("재배포 하시겠습니까?")) {
$.ajax({ $.ajax({
@@ -217,15 +270,41 @@
// Display Portal User details // Display Portal User details
var portalUser = data.portalUser; var portalUser = data.portalUser;
var portalOrg = portalUser ? portalUser.portalOrgUIs : null;
// 삭제된 대상 사용자/법인 판별
var userDeleted = !portalUser
|| portalUser.userName === '반려된 사용자'
|| portalUser.userName === '삭제된 사용자';
var orgDeleted = !portalOrg
|| portalOrg.orgStatus === 'REMOVED'
|| portalOrg.orgName === '삭제된법인';
var deletedTarget = userDeleted || orgDeleted;
if (portalUser) {
$("#loginId").text(portalUser.loginId); $("#loginId").text(portalUser.loginId);
$("#userName").text(portalUser.userName); $("#userName").text(portalUser.userName);
$("#mobileNumber").text(portalUser.mobileNumber); $("#mobileNumber").text(portalUser.mobileNumber);
$("#userStatusDescription").text(portalUser.userStatusDescription); $("#userStatusDescription").text(portalUser.userStatusDescription);
$("#roleCodeDescription").text(portalUser.roleCodeDescription); $("#roleCodeDescription").text(portalUser.roleCodeDescription);
if (data.approvers.some(approver => } else {
$("#loginId, #userName, #mobileNumber, #userStatusDescription, #roleCodeDescription").text("삭제된 사용자");
}
var isCurrentApprover = data.approvers.some(approver =>
approver.approverStatus === 'CURRENT' && approver.approverStatus === 'CURRENT' &&
approver.user.USERID === loginUser approver.user && approver.user.USERID === loginUser
)) { );
if (deletedTarget) {
// 삭제된 사용자/법인 포함 → 승인·반려 불가. 버튼은 비활성(disable) 표기, 삭제 버튼 노출
$("#btn_user_approve, #btn_user_reject").show().prop("disabled", true);
document.getElementById("btn_user_force_reject").style.display = "none";
document.getElementById("btn_user_delete").style.display = "";
} else {
$("#btn_user_approve, #btn_user_reject").prop("disabled", false);
document.getElementById("btn_user_delete").style.display = "none";
if (isCurrentApprover) {
document.getElementById("btn_user_approve").style.display = ""; document.getElementById("btn_user_approve").style.display = "";
document.getElementById("btn_user_reject").style.display = ""; document.getElementById("btn_user_reject").style.display = "";
} else { } else {
@@ -233,12 +312,21 @@
document.getElementById("btn_user_reject").style.display = "none"; document.getElementById("btn_user_reject").style.display = "none";
} }
// 삭제된 승인자(USERID 없음)로 진행 불가한 건: 요청됨/진행중 상태면 승인자가 아니어도 강제 반려 허용
var hasDeletedApprover = data.approvers.some(approver =>
!approver.user || !approver.user.USERID
);
var isProgressStatus = data.approvalStatus.code === 'REQUESTED' || data.approvalStatus.code === 'PROCESSING';
document.getElementById("btn_user_force_reject").style.display =
(hasDeletedApprover && isProgressStatus && !isCurrentApprover) ? "" : "none";
}
if (data.approvalStatus.description === '배포실패'){ if (data.approvalStatus.description === '배포실패'){
document.getElementById("btn_user_redeploy").style.display = ""; document.getElementById("btn_user_redeploy").style.display = "";
} }
// Display Portal Org details // Display Portal Org details
var portalOrg = portalUser.portalOrgUIs; if (portalOrg) {
$("#orgName").text(portalOrg.orgName); $("#orgName").text(portalOrg.orgName);
$("#orgStatusDescription").text(portalOrg.orgStatusDescription); $("#orgStatusDescription").text(portalOrg.orgStatusDescription);
$("#serviceName").text(portalOrg.serviceName); $("#serviceName").text(portalOrg.serviceName);
@@ -253,8 +341,12 @@
$("#orgSectors").text(portalOrg.orgSectors || ""); // 업태 $("#orgSectors").text(portalOrg.orgSectors || ""); // 업태
$("#orgCode").text(portalOrg.orgCode); $("#orgCode").text(portalOrg.orgCode);
$("#ipWhitelist").text(portalOrg.ipWhitelist || ""); $("#ipWhitelist").text(portalOrg.ipWhitelist || "");
} else {
$("#orgName").text("삭제된법인");
$("#orgStatusDescription, #serviceName, #corpRegNo, #compRegNo, #scPhoneNumber, #orgPhoneNumber, #ceoName, #orgIndustryType, #orgAddr, #orgSectors, #orgCode, #ipWhitelist").text("");
}
if (portalOrg.compRegFile) { if (portalOrg && portalOrg.compRegFile) {
$("#fileName").text(portalOrg.fileName); $("#fileName").text(portalOrg.fileName);
if (portalOrg.fileName) { if (portalOrg.fileName) {
$("#fileWrapper").show().off('click').on('click', function() { $("#fileWrapper").show().off('click').on('click', function() {
@@ -294,15 +386,18 @@
// 사용자 정보 업데이트 // 사용자 정보 업데이트
var portalUser = data.portalUser; var portalUser = data.portalUser;
if (portalUser) {
$("#loginId").text(portalUser.loginId); $("#loginId").text(portalUser.loginId);
$("#userName").text(portalUser.userName); $("#userName").text(portalUser.userName);
$("#mobileNumber").text(portalUser.mobileNumber); $("#mobileNumber").text(portalUser.mobileNumber);
$("#userStatusDescription").text(portalUser.userStatusDescription); $("#userStatusDescription").text(portalUser.userStatusDescription);
$("#roleCodeDescription").text(portalUser.roleCodeDescription); $("#roleCodeDescription").text(portalUser.roleCodeDescription);
} else {
$("#loginId, #userName, #mobileNumber, #userStatusDescription, #roleCodeDescription").text("삭제된 사용자");
}
// 법인 정보 업데이트 // 법인 정보 업데이트
var portalOrg = portalUser.portalOrgUIs; updateOrgInfo(portalUser ? portalUser.portalOrgUIs : null);
updateOrgInfo(portalOrg);
// 마스킹 상태에 따라 데이터를 다르게 표시 // 마스킹 상태에 따라 데이터를 다르게 표시
var isMasked = $("#btn_unmask").attr("status") === "DETAIL"; var isMasked = $("#btn_unmask").attr("status") === "DETAIL";
@@ -344,6 +439,11 @@
// 법인 정보 업데이트 함수 // 법인 정보 업데이트 함수
function updateOrgInfo(portalOrg) { function updateOrgInfo(portalOrg) {
if (!portalOrg) {
$("#orgName").text("삭제된법인");
$("#orgStatusDescription, #serviceName, #corpRegNo, #compRegNo, #scPhoneNumber, #orgPhoneNumber, #ceoName, #orgIndustryType, #orgAddr, #orgSectors, #orgCode, #ipWhitelist").text("");
return;
}
$("#orgName").text(portalOrg.orgName); $("#orgName").text(portalOrg.orgName);
$("#orgStatusDescription").text(portalOrg.orgStatusDescription); $("#orgStatusDescription").text(portalOrg.orgStatusDescription);
$("#serviceName").text(portalOrg.serviceName); $("#serviceName").text(portalOrg.serviceName);
@@ -486,6 +586,8 @@
<div class="popup_button"> <div class="popup_button">
<button type="button" class="cssbtn" style="display: none;" id="btn_user_approve" level="W"><i class="material-icons">check</i>승인</button> <button type="button" class="cssbtn" style="display: none;" id="btn_user_approve" level="W"><i class="material-icons">check</i>승인</button>
<button type="button" class="cssbtn" style="display: none;" id="btn_user_reject" level="W"><i class="material-icons">close</i>반려</button> <button type="button" class="cssbtn" style="display: none;" id="btn_user_reject" level="W"><i class="material-icons">close</i>반려</button>
<button type="button" class="cssbtn" style="display: none;" id="btn_user_force_reject" level="W"><i class="material-icons">block</i>강제 반려</button>
<button type="button" class="cssbtn" style="display: none;" id="btn_user_delete" level="W"><i class="material-icons">delete</i>삭제</button>
<button type="button" class="cssbtn" style="display: none;" id="btn_user_redeploy" level="W"><i class="material-icons">close</i>재처리</button> <button type="button" class="cssbtn" style="display: none;" id="btn_user_redeploy" level="W"><i class="material-icons">close</i>재처리</button>
<button type="button" class="cssbtn" id="btn_close" level="R"><i class="material-icons">cancel</i> <%= localeMessage.getString("button.close") %> <button type="button" class="cssbtn" id="btn_close" level="R"><i class="material-icons">cancel</i> <%= localeMessage.getString("button.close") %>
</button> </button>
@@ -192,7 +192,6 @@
<option value="">전체</option> <option value="">전체</option>
<option value="EMAIL">EMAIL</option> <option value="EMAIL">EMAIL</option>
<option value="SMS">SMS</option> <option value="SMS">SMS</option>
<option value="KAKAO">KAKAO</option>
<option value="MESSENGER">MESSENGER</option> <option value="MESSENGER">MESSENGER</option>
</select> </select>
</div> </div>
@@ -22,6 +22,8 @@
function formatInquiryStatus(cellvalue, options, rowObject) { function formatInquiryStatus(cellvalue, options, rowObject) {
if (cellvalue == 'PENDING') { if (cellvalue == 'PENDING') {
return '<span style="color: red;">문의중</span>'; return '<span style="color: red;">문의중</span>';
} else if (cellvalue == 'REVIEWING') {
return '<span >문의검토중</span>'
} else if (cellvalue == 'RESPONDED') { } else if (cellvalue == 'RESPONDED') {
return '<span >답변완료</span>' return '<span >답변완료</span>'
} else if (cellvalue == 'CLOSED') { } else if (cellvalue == 'CLOSED') {
@@ -31,6 +33,17 @@
} }
} }
function formatVisibility(cellvalue, options, rowObject) {
if (cellvalue == 'PRIVATE') {
return '<span style="color:#c0392b; font-weight:bold;">비공개</span>';
} else if (cellvalue == 'ALL') {
return '전체공개';
} else if (cellvalue == 'ORG') {
return '법인공개';
}
return cellvalue;
}
function formatFile(cellvalue, options, rowObject) { function formatFile(cellvalue, options, rowObject) {
var iconPath = '${pageContext.request.contextPath}/images/icon_file.png'; var iconPath = '${pageContext.request.contextPath}/images/icon_file.png';
var icon = rowObject.hasAttachment ? '<img src="' + iconPath + '" alt="File Attached" style="vertical-align: middle; margin-left: 5px; width: 16px; height: 16px;">' : ''; var icon = rowObject.hasAttachment ? '<img src="' + iconPath + '" alt="File Attached" style="vertical-align: middle; margin-left: 5px; width: 16px; height: 16px;">' : '';
@@ -99,13 +112,14 @@
mtype: 'POST', mtype: 'POST',
url: url, url: url,
postData : { cmd : 'LIST' }, postData : { cmd : 'LIST' },
colNames:['No.', 'id', '제목', '법인명', '상태', '작성자', '등록일', '답변자','답변일'], colNames:['No.', 'id', '제목', '법인명', '상태', '공개범위', '작성자', '등록일', '답변자','답변일'],
colModel:[ colModel:[
{ name : 'rowNum' , align:'center', width:50, sortable:false }, { name : 'rowNum' , align:'center', width:50, sortable:false },
{ name : 'id' , align:'center', key:true, hidden:true}, { name : 'id' , align:'center', key:true, hidden:true},
{ name : 'inquirySubject' , align:'left' , width:200 , formatter: formatFile}, { name : 'inquirySubject' , align:'left' , width:200 , formatter: formatFile},
{ name : 'orgName' , align:'center' , width:100 , }, { name : 'orgName' , align:'center' , width:100 , },
{ name : 'inquiryStatus' , align:'center', width:70, formatter: formatInquiryStatus }, { name : 'inquiryStatus' , align:'center', width:70, formatter: formatInquiryStatus },
{ name : 'visibility' , align:'center', width:70, formatter: formatVisibility },
{ name : 'inquirer.userName', align:'center', width:70 }, { name : 'inquirer.userName', align:'center', width:70 },
{ name : 'createdDate' , align:'center', width:100, formatter: timeStampFormat }, { name : 'createdDate' , align:'center', width:100, formatter: timeStampFormat },
{ name : 'responderName' , align:'center', width:70 }, { name : 'responderName' , align:'center', width:70 },
@@ -207,6 +221,7 @@
<select name="searchInquiryStatus"> <select name="searchInquiryStatus">
<option value="">전체</option> <option value="">전체</option>
<option value="PENDING">문의중</option> <option value="PENDING">문의중</option>
<option value="REVIEWING">문의검토중</option>
<option value="RESPONDED">답변완료</option> <option value="RESPONDED">답변완료</option>
<option value="CLOSED">답변종료</option> <option value="CLOSED">답변종료</option>
</select> </select>
@@ -79,12 +79,46 @@
margin-left: 2px; margin-left: 2px;
font-weight: bold; font-weight: bold;
} }
.reason-modal-overlay {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.4);
z-index: 1000;
}
.reason-modal-box {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: #fff;
border-radius: 6px;
padding: 20px;
width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.reason-modal-box h3 {
margin: 0 0 12px;
font-size: 16px;
}
.reason-modal-box textarea {
width: 100%;
min-height: 80px;
box-sizing: border-box;
}
.reason-modal-actions {
margin-top: 12px;
text-align: right;
}
</style> </style>
<script language="javascript"> <script language="javascript">
var url = '<c:url value="/onl/apim/portalinquiry/portalInquiryMan.json" />'; var url = '<c:url value="/onl/apim/portalinquiry/portalInquiryMan.json" />';
var url_view = '<c:url value="/onl/apim/portalinquiry/portalInquiryMan.view" />'; var url_view = '<c:url value="/onl/apim/portalinquiry/portalInquiryMan.view" />';
var file_download = '<c:url value="/file/download.do" />'; var file_download = '<c:url value="/file/download.do" />';
var inquiryStatus = 'PENDING'; var inquiryStatus = 'PENDING';
var originalVisibility = 'ORG';
var reasonModalCallback = null;
function decodeHTMLEntities(text) { function decodeHTMLEntities(text) {
var textArea = document.createElement('textarea'); var textArea = document.createElement('textarea');
@@ -109,6 +143,8 @@
inquiryStatus = data.inquiryStatus; inquiryStatus = data.inquiryStatus;
if (data.inquiryStatus == 'PENDING') { if (data.inquiryStatus == 'PENDING') {
$("#inquiryStatus").text('문의중'); $("#inquiryStatus").text('문의중');
} else if (data.inquiryStatus == 'REVIEWING') {
$("#inquiryStatus").text('문의검토중');
} else if (data.inquiryStatus == 'RESPONDED') { } else if (data.inquiryStatus == 'RESPONDED') {
$("#inquiryStatus").text('답변완료'); $("#inquiryStatus").text('답변완료');
} else if (data.inquiryStatus == 'CLOSED') { } else if (data.inquiryStatus == 'CLOSED') {
@@ -122,6 +158,24 @@
var decodedContent = decodeHTMLEntities(data.responseDetail); var decodedContent = decodeHTMLEntities(data.responseDetail);
$("#responseDetail").summernote('code', decodedContent || ''); $("#responseDetail").summernote('code', decodedContent || '');
// 공개범위 / 조회수
$('#visibilitySelect').val(data.visibility || 'ORG');
originalVisibility = data.visibility || 'ORG';
$('#viewCount').text(data.viewCount || 0);
// 조회수 증가 (sessionStorage dedup — 새로고침 시 중복 증가 억제)
var viewedKey = 'inquiry_viewed_' + key;
if (!sessionStorage.getItem(viewedKey)) {
$.ajax({
type: "POST",
url: url,
data: {cmd: 'INCREMENT_VIEW', id: key},
success: function () {
sessionStorage.setItem(viewedKey, 'true');
}
});
}
// 댓글 표시 // 댓글 표시
listComment(key); listComment(key);
@@ -147,8 +201,17 @@
if (!data || data.length === 0) return; if (!data || data.length === 0) return;
$.each(data, function (i, comment) { $.each(data, function (i, comment) {
var date = comment.createdDate ? timeStampFormat(comment.createdDate) : ''; var date = comment.createdDate ? timeStampFormat(comment.createdDate) : '';
var isPrivate = comment.visibility === 'PRIVATE';
var row = $('<div>').addClass('info-row'); var row = $('<div>').addClass('info-row');
$('<div>').addClass('info-label').css('color', '#555').text(comment.writerName || '').appendTo(row); if (isPrivate) {
// 비공개 댓글은 색상으로 구별
row.css({'background-color': '#FFF5F5', 'border-radius': '4px', 'padding': '4px'});
}
var labelDiv = $('<div>').addClass('info-label').css('color', '#555').text(comment.writerName || '');
if (isPrivate) {
$('<span>').text(' 비공개').css({'color': '#c0392b', 'font-size': '0.85em', 'font-weight': 'bold'}).appendTo(labelDiv);
}
labelDiv.appendTo(row);
var content = $('<div>').addClass('info-content'); var content = $('<div>').addClass('info-content');
$('<div>').css('white-space', 'pre-wrap').text(comment.commentDetail).appendTo(content); $('<div>').css('white-space', 'pre-wrap').text(comment.commentDetail).appendTo(content);
var meta = $('<div>').css({'font-size': '0.9em', 'color': '#999', 'margin-top': '2px'}); var meta = $('<div>').css({'font-size': '0.9em', 'color': '#999', 'margin-top': '2px'});
@@ -159,6 +222,19 @@
})(comment.id, comment.inquiryId)) })(comment.id, comment.inquiryId))
.appendTo(meta); .appendTo(meta);
meta.appendTo(content); meta.appendTo(content);
// 댓글 공개범위 변경 컨트롤 (사유 필수)
var visCtrl = $('<div>').css({'margin-top': '4px', 'font-size': '0.85em'});
var sel = $('<select>').append('<option value="ALL">공개</option>').append('<option value="PRIVATE">비공개</option>');
sel.val(comment.visibility || 'ALL');
var reasonInput = $('<input>').attr({'type': 'text', 'placeholder': '변경 사유'}).css({'margin-left': '4px'});
var applyBtn = $('<button>').attr('type', 'button').addClass('cssbtn smallBtn2').css({'margin-left': '4px', 'font-size': '0.85em', 'padding': '1px 6px'})
.text('공개범위변경').on('click', (function(id, inquiryId, selEl, reasonEl) {
return function() { changeCommentVisibility(id, inquiryId, selEl.val(), reasonEl.val()); };
})(comment.id, comment.inquiryId, sel, reasonInput));
visCtrl.append(sel).append(reasonInput).append(applyBtn);
visCtrl.appendTo(content);
content.appendTo(row); content.appendTo(row);
row.appendTo($commentList); row.appendTo($commentList);
}); });
@@ -169,6 +245,84 @@
}); });
} }
function openReasonModal(callback) {
reasonModalCallback = callback;
$('#reasonModalInput').val('');
$('#reasonModal').show();
$('#reasonModalInput').focus();
}
function closeReasonModal() {
$('#reasonModal').hide();
reasonModalCallback = null;
}
// 답변(summernote) 내용이 실제로 입력됐는지 확인
function hasAnswerContent() {
var v = $('#responseDetail').summernote('code');
var text = $('<div>').html(v || '').text().replace(/ /g, ' ').trim();
return text.length > 0;
}
function saveInquiryAnswer(returnUrl) {
var postData = $('#ajaxForm').serializeArray();
postData.push({name: "cmd", value: "INSERT"});
$.ajax({
type: "POST",
url: url,
data: postData,
success: function (json) {
alert("<%= localeMessage.getString("common.saveMsg") %>");
goNav(returnUrl);
},
error: function (e) {
alert(e.responseText);
}
});
}
// 게시물 공개범위 변경(사유 필수). 성공 후 답변 내용이 있으면 이어서 저장.
function changeInquiryVisibility(newVis, reason, returnUrl, thenSaveAnswer) {
$.ajax({
type: "POST",
url: url,
data: {cmd: 'VISIBILITY', serviceType: 'APIGW', targetType: 'INQUIRY', id: $('#id').val(),
visibility: newVis, auditReason: reason},
success: function () {
originalVisibility = newVis;
if (thenSaveAnswer) {
saveInquiryAnswer(returnUrl);
} else {
alert('변경되었습니다.');
goNav(returnUrl);
}
},
error: function (e) {
alert(e.responseText);
}
});
}
function changeCommentVisibility(commentId, inquiryId, visibility, reason) {
if (!reason || !reason.trim()) {
alert('공개범위 변경 사유를 입력하세요.');
return;
}
if (!confirm('댓글 공개범위를 변경하시겠습니까?')) return;
$.ajax({
type: "POST",
url: url,
data: {cmd: 'VISIBILITY', serviceType: 'APIGW', targetType: 'COMMENT', id: commentId, visibility: visibility, auditReason: reason},
success: function () {
alert('변경되었습니다.');
listComment(inquiryId);
},
error: function (e) {
alert(e.responseText);
}
});
}
function deleteComment(commentId, inquiryId) { function deleteComment(commentId, inquiryId) {
if (inquiryStatus == 'CLOSED') { if (inquiryStatus == 'CLOSED') {
alert('답변이 종료되어 삭제할 수 없습니다'); alert('답변이 종료되어 삭제할 수 없습니다');
@@ -208,24 +362,38 @@
alert('답변이 종료되어 수정할 수 없습니다'); alert('답변이 종료되어 수정할 수 없습니다');
return; return;
} }
var newVis = $('#visibilitySelect').val();
var visChanged = (newVis !== originalVisibility);
if (visChanged) {
// 공개범위 변경 감지 → 사유 입력 모달 (답변 내용이 있으면 함께 저장)
var saveAnswerToo = hasAnswerContent();
openReasonModal(function (reason) {
changeInquiryVisibility(newVis, reason, returnUrl, saveAnswerToo);
});
return;
}
// 공개범위 변경 없음 → 기존 답변 저장
if (!checkRequired("ajaxForm")) return; if (!checkRequired("ajaxForm")) return;
if (!confirm("<%= localeMessage.getString("common.checkSave")%>")) return; if (!confirm("<%= localeMessage.getString("common.checkSave")%>")) return;
saveInquiryAnswer(returnUrl);
var postData = $('#ajaxForm').serializeArray();
postData.push({name: "cmd", value: "INSERT"});
$.ajax({
type: "POST",
url: url,
data: postData,
success: function (json) {
alert("<%= localeMessage.getString("common.saveMsg") %>");
goNav(returnUrl);
},
error: function (e) {
alert(e.responseText);
}
}); });
$("#reasonModalOk").click(function () {
var reason = ($('#reasonModalInput').val() || '').trim();
if (!reason) {
alert('변경 사유를 입력하세요.');
return;
}
var cb = reasonModalCallback;
closeReasonModal();
if (cb) cb(reason);
});
$("#reasonModalCancel").click(function () {
closeReasonModal();
}); });
$("#btn_comment").click(function () { $("#btn_comment").click(function () {
@@ -308,6 +476,21 @@
<div class="info-label">작성일</div> <div class="info-label">작성일</div>
<div id="createdDate" class="info-content"></div> <div id="createdDate" class="info-content"></div>
</div> </div>
<div class="info-row">
<div class="info-label">조회수</div>
<div id="viewCount" class="info-content">0</div>
</div>
<div class="info-row">
<div class="info-label">공개범위</div>
<div class="info-content" style="display:flex; gap:8px; align-items:center;">
<select id="visibilitySelect">
<option value="ALL">전체공개</option>
<option value="ORG">법인공개</option>
<option value="PRIVATE">비공개</option>
</select>
<span style="font-size:0.85em; color:#999;">변경 후 상단 [수정] 버튼을 누르면 사유 입력창이 표시됩니다.</span>
</div>
</div>
<div class="info-row" id="attachFileRow" style="display: none;"> <div class="info-row" id="attachFileRow" style="display: none;">
<div class="info-label">첨부파일</div> <div class="info-label">첨부파일</div>
<div id="attachFiles" class="info-content"></div> <div id="attachFiles" class="info-content"></div>
@@ -356,5 +539,17 @@
</form> </form>
</div> </div>
</div> </div>
<!-- 공개범위 변경 사유 입력 모달 -->
<div id="reasonModal" class="reason-modal-overlay">
<div class="reason-modal-box">
<h3>공개범위 변경 사유</h3>
<textarea id="reasonModalInput" placeholder="변경 사유를 입력하세요."></textarea>
<div class="reason-modal-actions">
<button type="button" class="cssbtn" id="reasonModalOk">확인</button>
<button type="button" class="cssbtn" id="reasonModalCancel">취소</button>
</div>
</div>
</div>
</body> </body>
</html> </html>
@@ -0,0 +1,550 @@
<%@ page language="java" contentType="text/html; charset=utf-8" %>
<%@ page import="java.io.*" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
%>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<jsp:include page="/jsp/common/include/script.jsp"/>
<jsp:include page="/jsp/common/include/portal.jsp"/>
<style>
/* ── 스윔레인 보드 ── */
.menu-board { display: flex; gap: 12px; align-items: flex-start; }
.menu-lane-strip { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 10px; flex: 1 1 auto; }
.menu-lane { background: #f1f3f5; border-radius: 10px; min-width: 230px; max-width: 230px; flex: 0 0 230px; }
.menu-lane.unplaced { background: #fff4e6; border: 1px dashed #fd7e14; }
.lane-header { display: flex; align-items: center; gap: 6px; padding: 10px 12px;
border-bottom: 1px solid #dee2e6; cursor: grab; }
.menu-lane.unplaced .lane-header { cursor: default; }
.lane-title { font-weight: 700; font-size: 14px; flex: 1 1 auto; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.lane-body { list-style: none; margin: 0; padding: 8px; min-height: 60px; }
.menu-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px;
padding: 8px 10px; margin-bottom: 8px; cursor: grab;
box-shadow: 0 1px 2px rgba(0,0,0,0.06); }
.menu-card.hidden-item, .menu-lane.hidden-item > .lane-header { opacity: 0.45; }
.menu-card .card-name { font-size: 13px; font-weight: 600; }
.menu-card .card-id { font-size: 11px; color: #868e96; }
.menu-card .card-path { font-size: 11px; color: #495057; word-break: break-all; }
.card-actions, .lane-actions { display: flex; gap: 4px; }
.icon-btn { border: none; background: transparent; padding: 1px 3px; cursor: pointer;
font-size: 14px; color: #495057; }
.icon-btn:hover { color: #0d6efd; }
.icon-btn.danger:hover { color: #dc3545; }
.badge-src { font-size: 10px; }
.role-check-group { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.role-check-group .form-check { min-width: 45%; }
.dflt-hint { font-size: 11px; color: #868e96; }
.board-toolbar { display: flex; gap: 6px; margin-bottom: 10px; align-items: center; }
.board-toolbar .spacer { flex: 1 1 auto; }
.sortable-placeholder { border: 2px dashed #adb5bd; border-radius: 8px; height: 48px;
margin-bottom: 8px; background: #e9ecef; }
.lane-placeholder { border: 2px dashed #adb5bd; border-radius: 10px; min-width: 230px; }
</style>
<script language="javascript">
// AuthorizeInterceptor 는 serviceType 을 "요청 파라미터"에서 읽으므로
// 모든 .json 호출 URL 에 serviceType 을 부착한다 (누락 시 권한 조회 실패 → Access denied)
var url = urlAddServiceType('<c:url value="/onl/apim/portalmenu/portalMenuMan.json" />');
// 화면 상태 (저장 버튼 클릭 시에만 서버 반영 — 드래그/토글은 클라이언트 상태만 변경)
var state = { itemsById: {}, lanes: [], unplaced: [], roles: [] };
var dirty = false;
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function loadAll(afterFn) {
$.post(url, { cmd: 'LIST_ALL' }, function (data) {
buildState(data);
render();
dirty = false;
if (afterFn) afterFn();
}, 'json').fail(ajaxFail);
}
function buildState(data) {
state.itemsById = {};
state.roles = data.roles || [];
(data.items || []).forEach(function (it) { state.itemsById[it.menuId] = it; });
var placedTop = [], childrenByParent = {}, unplaced = [];
(data.items || []).forEach(function (it) {
if (!it.placed) { unplaced.push(it.menuId); return; }
if (!it.parentId) {
placedTop.push(it);
} else {
(childrenByParent[it.parentId] = childrenByParent[it.parentId] || []).push(it);
}
});
placedTop.sort(function (a, b) { return (a.sortOrder || 0) - (b.sortOrder || 0); });
state.lanes = placedTop.map(function (top) {
var kids = (childrenByParent[top.menuId] || [])
.sort(function (a, b) { return (a.sortOrder || 0) - (b.sortOrder || 0); })
.map(function (c) { return { menuId: c.menuId, visibleYn: c.visibleYn || 'Y' }; });
delete childrenByParent[top.menuId];
return { menuId: top.menuId, visibleYn: top.visibleYn || 'Y', children: kids };
});
// 부모가 미배치/부재인 고아 배치는 미배치로 강등
Object.keys(childrenByParent).forEach(function (pid) {
childrenByParent[pid].forEach(function (c) { unplaced.push(c.menuId); });
});
state.unplaced = unplaced;
}
// ── 렌더 ──
function cardHtml(menuId, visibleYn, inLane) {
var it = state.itemsById[menuId];
if (!it) return '';
var hidden = visibleYn === 'N';
var srcBadge = it.sourceType === 'PORTAL'
? '<span class="badge text-bg-secondary badge-src">기본</span>'
: '<span class="badge text-bg-warning badge-src">커스텀</span>';
var grpBadge = it.groupYn === 'Y' ? ' <span class="badge text-bg-info badge-src">그룹</span>' : '';
var placeBtn = (!inLane && it.groupYn === 'Y')
? '<button type="button" class="icon-btn" title="최상위 레인으로 배치" onclick="placeAsLane(\'' + menuId + '\')"><i class="bi bi-box-arrow-in-up"></i></button>'
: '';
var eyeBtn = inLane
? '<button type="button" class="icon-btn" title="노출/숨김" onclick="toggleVisible(\'' + menuId + '\')"><i class="bi ' + (hidden ? 'bi-eye-slash' : 'bi-eye') + '"></i></button>'
: '';
return '<li class="menu-card' + (hidden ? ' hidden-item' : '') + '" data-menu-id="' + escapeHtml(menuId) + '">'
+ '<div class="d-flex align-items-start">'
+ '<div class="flex-grow-1">'
+ '<div class="card-name">' + escapeHtml(it.menuName) + ' ' + srcBadge + grpBadge + '</div>'
+ '<div class="card-id">' + escapeHtml(it.menuId) + '</div>'
+ (it.menuPath ? '<div class="card-path">' + escapeHtml(it.menuPath) + '</div>' : '')
+ '</div>'
+ '<div class="card-actions">'
+ placeBtn + eyeBtn
+ '<button type="button" class="icon-btn" title="수정" onclick="openModal(\'' + menuId + '\')"><i class="bi bi-pencil"></i></button>'
+ '<button type="button" class="icon-btn danger" level="W" title="' + (it.sourceType === 'PORTAL' ? '미배치로 이동' : '삭제') + '" onclick="minusItem(\'' + menuId + '\')"><i class="bi bi-dash-circle"></i></button>'
+ '</div></div></li>';
}
function laneHtml(lane) {
var it = state.itemsById[lane.menuId];
if (!it) return '';
var hidden = lane.visibleYn === 'N';
var sectionBadge = it.menuSection === 'MYPAGE'
? '<span class="badge text-bg-primary badge-src">마이페이지</span>'
: '<span class="badge text-bg-success badge-src">GNB</span>';
var srcBadge = it.sourceType === 'PORTAL' ? '' : ' <span class="badge text-bg-warning badge-src">커스텀</span>';
var cards = lane.children.map(function (c) { return cardHtml(c.menuId, c.visibleYn, true); }).join('');
return '<div class="menu-lane' + (hidden ? ' hidden-item' : '') + '" data-menu-id="' + escapeHtml(lane.menuId) + '">'
+ '<div class="lane-header lane-drag-handle">'
+ '<span class="lane-title">' + escapeHtml(it.menuName) + '</span>'
+ sectionBadge + srcBadge
+ '<div class="lane-actions">'
+ '<button type="button" class="icon-btn" title="노출/숨김" onclick="toggleVisible(\'' + lane.menuId + '\')"><i class="bi ' + (hidden ? 'bi-eye-slash' : 'bi-eye') + '"></i></button>'
+ '<button type="button" class="icon-btn" title="수정" onclick="openModal(\'' + lane.menuId + '\')"><i class="bi bi-pencil"></i></button>'
+ '<button type="button" class="icon-btn danger" level="W" title="레인 미배치" onclick="unplaceLane(\'' + lane.menuId + '\')"><i class="bi bi-dash-circle"></i></button>'
+ '</div></div>'
+ '<ul class="lane-body" data-lane-id="' + escapeHtml(lane.menuId) + '">' + cards + '</ul>'
+ '</div>';
}
function render() {
var strip = state.lanes.map(laneHtml).join('');
$('#laneStrip').html(strip);
$('#unplacedBody').html(state.unplaced.map(function (id) { return cardHtml(id, 'Y', false); }).join(''));
initSortables();
buttonControl();
}
function initSortables() {
$('#laneStrip').sortable({
items: '.menu-lane',
handle: '.lane-drag-handle',
placeholder: 'lane-placeholder',
tolerance: 'pointer',
update: function () { syncFromDom(); }
});
$('.lane-body, #unplacedBody').sortable({
connectWith: '.lane-body, #unplacedBody',
items: '.menu-card',
placeholder: 'sortable-placeholder',
tolerance: 'pointer',
stop: function () { syncFromDom(); }
});
}
// 드래그 종료 후 DOM 순서 → 상태 동기화 (visible 값은 기존 상태 유지)
function syncFromDom() {
var visibleMap = {};
state.lanes.forEach(function (l) {
visibleMap[l.menuId] = l.visibleYn;
l.children.forEach(function (c) { visibleMap[c.menuId] = c.visibleYn; });
});
var lanes = [];
$('#laneStrip .menu-lane').each(function () {
var laneId = $(this).data('menu-id');
var children = [];
$(this).find('.lane-body .menu-card').each(function () {
var id = $(this).data('menu-id');
children.push({ menuId: id, visibleYn: visibleMap[id] || 'Y' });
});
lanes.push({ menuId: laneId, visibleYn: visibleMap[laneId] || 'Y', children: children });
});
var unplaced = [];
$('#unplacedBody .menu-card').each(function () { unplaced.push($(this).data('menu-id')); });
state.lanes = lanes;
state.unplaced = unplaced;
dirty = true;
render();
}
// ── 카드/레인 조작 ──
function toggleVisible(menuId) {
state.lanes.forEach(function (l) {
if (l.menuId === menuId) l.visibleYn = (l.visibleYn === 'N' ? 'Y' : 'N');
l.children.forEach(function (c) {
if (c.menuId === menuId) c.visibleYn = (c.visibleYn === 'N' ? 'Y' : 'N');
});
});
dirty = true;
render();
}
function placeAsLane(menuId) {
state.unplaced = state.unplaced.filter(function (id) { return id !== menuId; });
state.lanes.push({ menuId: menuId, visibleYn: 'Y', children: [] });
dirty = true;
render();
}
function minusItem(menuId) {
var it = state.itemsById[menuId];
if (!it) return;
if (it.sourceType === 'PORTAL') {
removeFromBoard(menuId);
if (state.unplaced.indexOf(menuId) < 0) state.unplaced.push(menuId);
dirty = true;
render();
} else {
if (!confirm('커스텀 항목은 미배치 시 삭제됩니다.\n[' + it.menuName + '] 을(를) 삭제하시겠습니까?')) return;
$.post(url, { cmd: 'DELETE', menuId: menuId }, function () {
removeFromBoard(menuId);
state.unplaced = state.unplaced.filter(function (id) { return id !== menuId; });
delete state.itemsById[menuId];
dirty = true;
render();
}, 'json').fail(ajaxFail);
}
}
function unplaceLane(laneId) {
var lane = null;
state.lanes = state.lanes.filter(function (l) {
if (l.menuId === laneId) { lane = l; return false; }
return true;
});
if (!lane) return;
lane.children.forEach(function (c) {
if (state.unplaced.indexOf(c.menuId) < 0) state.unplaced.push(c.menuId);
});
var it = state.itemsById[laneId];
if (it && it.sourceType !== 'PORTAL') {
if (confirm('커스텀 항목은 미배치 시 삭제됩니다.\n[' + it.menuName + '] 항목을 삭제하시겠습니까?')) {
$.post(url, { cmd: 'DELETE', menuId: laneId }, function () {
delete state.itemsById[laneId];
render();
}, 'json').fail(ajaxFail);
} else {
state.unplaced.push(laneId);
}
} else {
if (state.unplaced.indexOf(laneId) < 0) state.unplaced.push(laneId);
}
dirty = true;
render();
}
function removeFromBoard(menuId) {
state.lanes = state.lanes.filter(function (l) { return l.menuId !== menuId; });
state.lanes.forEach(function (l) {
l.children = l.children.filter(function (c) { return c.menuId !== menuId; });
});
}
// ── 저장 / 초기화 / 캐시 Reload ──
function savePlacements() {
// 미배치 상태의 커스텀 항목은 저장 시 삭제 처리 (스펙: 커스텀은 미배치=삭제)
var unplacedAdmin = state.unplaced.filter(function (id) {
var it = state.itemsById[id];
return it && it.sourceType !== 'PORTAL';
});
if (unplacedAdmin.length > 0) {
var names = unplacedAdmin.map(function (id) { return state.itemsById[id].menuName; }).join(', ');
if (!confirm('미배치된 커스텀 항목은 저장 시 삭제됩니다: ' + names + '\n계속하시겠습니까?')) return;
}
var rows = [];
state.lanes.forEach(function (l, i) {
rows.push({ menuId: l.menuId, parentId: null, sortOrder: (i + 1) * 10, visibleYn: l.visibleYn });
l.children.forEach(function (c, j) {
rows.push({ menuId: c.menuId, parentId: l.menuId, sortOrder: (j + 1) * 10, visibleYn: c.visibleYn });
});
});
var deleteCalls = unplacedAdmin.map(function (id) {
return $.post(url, { cmd: 'DELETE', menuId: id });
});
$.when.apply($, deleteCalls).always(function () {
$.ajax({
url: url + '&cmd=TRANSACTION_PLACEMENT',
type: 'POST',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify(rows)
}).done(function () {
dirty = false;
loadAll(function () {
if (confirm('배치가 저장되었습니다.\n포탈 메뉴 캐시를 즉시 반영(Reload)하시겠습니까?\n(미반영 시 캐시 TTL 후 자동 반영)')) {
reloadPortalCache();
}
});
}).fail(ajaxFail);
});
}
function initializeMenus() {
if (!confirm('메뉴 초기화 시 포탈 기본(menu.yml) 값으로 복원되고\n커스텀 항목은 모두 삭제됩니다. 계속하시겠습니까?')) return;
$.post(url, { cmd: 'INITIALIZE' }, function () {
loadAll(function () { alert('메뉴가 초기화되었습니다.'); });
}, 'json').fail(ajaxFail);
}
function reloadPortalCache() {
$.post(url, { cmd: 'TRANSACTION_RELOAD' }, function (result) {
if (result && result.success) {
alert('포탈 메뉴 캐시 리로드 완료 (항목 ' + result.itemCount + '건, ' + result.reloadedAt + ')');
} else {
alert('캐시 리로드 실패: ' + (result ? result.message : '알 수 없는 오류'));
}
}, 'json').fail(ajaxFail);
}
function ajaxFail(xhr) {
var msg = '처리 중 오류가 발생했습니다.';
try {
var body = JSON.parse(xhr.responseText);
if (body.errorMsg) msg = body.errorMsg;
else if (body.message) msg = body.message;
} catch (e) { /* ignore */ }
alert(msg + ' (HTTP ' + xhr.status + ')');
}
// ── 추가/수정 모달 ──
var menuModal;
function roleChecksHtml(namePrefix, checkedCsv) {
var checked = (checkedCsv || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);
var html = '';
var all = [{ roleCode: 'AUTHENTICATED', roleName: '로그인 사용자', roleType: '-' }].concat(state.roles);
all.forEach(function (r) {
var id = namePrefix + '_' + r.roleCode;
html += '<div class="form-check form-check-sm">'
+ '<input class="form-check-input" type="checkbox" name="' + namePrefix + '" id="' + id + '" value="' + escapeHtml(r.roleCode) + '"'
+ (checked.indexOf(r.roleCode) >= 0 ? ' checked' : '') + '>'
+ '<label class="form-check-label" for="' + id + '" style="font-size:12px;">'
+ escapeHtml(r.roleName) + ' <span class="text-muted">(' + escapeHtml(r.roleCode) + ')</span></label>'
+ '</div>';
});
return html;
}
function openModal(menuId) {
var isNew = !menuId;
var it = isNew ? { sourceType: 'ADMIN', groupYn: 'N', menuSection: 'GNB', newWindowYn: 'N' }
: state.itemsById[menuId];
if (!it) return;
var isPortal = it.sourceType === 'PORTAL';
$('#modalTitle').text(isNew ? '메뉴 추가' : '메뉴 수정' + (isPortal ? ' (기본 항목)' : ''));
$('#fMenuId').val(it.menuId || '').prop('readonly', !isNew);
$('#fMenuName').val(it.menuName || '');
$('#fMenuPath').val(it.menuPath || '');
$('#fIconClass').val(it.iconClass || '');
$('#fMenuSection').val(it.menuSection || 'GNB').prop('disabled', isPortal);
$('#fGroupYn').prop('checked', it.groupYn === 'Y').prop('disabled', isPortal);
$('#fNewWindowYn').prop('checked', it.newWindowYn === 'Y');
$('#exposeRoleChecks').html(roleChecksHtml('exposeRole', it.exposeRoles));
$('#accessRoleChecks').html(roleChecksHtml('accessRole', it.accessRoles));
// 기본 항목: 기본값(DFLT_*) 병기
if (isPortal) {
$('#hintMenuName').text('기본값: ' + (it.dfltMenuName || '-'));
$('#hintMenuPath').text('기본값: ' + (it.dfltMenuPath || '-'));
$('#hintExposeRoles').text('기본값: ' + (it.dfltExposeRoles || '전체 노출'));
$('#hintAccessRoles').text('기본값: ' + (it.dfltAccessRoles || '제한 없음'));
$('.dflt-hint').show();
} else {
$('.dflt-hint').hide();
}
$('#modalMode').val(isNew ? 'INSERT' : 'UPDATE');
menuModal.show();
}
function submitModal() {
var cmd = $('#modalMode').val();
var data = {
cmd: cmd,
menuId: $('#fMenuId').val().trim(),
menuName: $('#fMenuName').val().trim(),
menuPath: $('#fMenuPath').val().trim(),
iconClass: $('#fIconClass').val().trim(),
menuSection: $('#fMenuSection').val(),
groupYn: $('#fGroupYn').is(':checked') ? 'Y' : 'N',
newWindowYn: $('#fNewWindowYn').is(':checked') ? 'Y' : 'N',
exposeRoles: $('input[name=exposeRole]:checked').map(function () { return this.value; }).get().join(','),
accessRoles: $('input[name=accessRole]:checked').map(function () { return this.value; }).get().join(',')
};
if (!data.menuId) { alert('메뉴 ID 를 입력하세요.'); return; }
if (cmd === 'INSERT' && !/^[a-z0-9-]+$/.test(data.menuId)) {
alert('메뉴 ID 는 kebab-case(소문자/숫자/하이픈)만 허용합니다.');
return;
}
if (!data.menuName) { alert('노출명을 입력하세요.'); return; }
$.post(url, data, function () {
menuModal.hide();
loadAll();
}, 'json').fail(ajaxFail);
}
$(document).ready(function () {
menuModal = new bootstrap.Modal(document.getElementById('menuModal'));
loadAll();
window.addEventListener('beforeunload', function (e) {
if (dirty) { e.preventDefault(); e.returnValue = ''; }
});
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path">
<li><a href="#">${rmsMenuPath}</a></li>
</ul>
</div><!-- end content_top -->
<div class="content_middle" id="content_middle">
<div class="title">포탈 메뉴 관리</div>
<div class="board-toolbar">
<button type="button" class="btn btn-sm btn-primary" level="W" onclick="openModal()">
<i class="bi bi-plus-lg"></i> 메뉴 추가</button>
<button type="button" class="btn btn-sm btn-outline-danger" level="W" onclick="initializeMenus()">
<i class="bi bi-arrow-counterclockwise"></i> 메뉴 초기화</button>
<span class="spacer"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" level="W" onclick="reloadPortalCache()">
<i class="bi bi-arrow-repeat"></i> 캐시 Reload</button>
<button type="button" class="btn btn-sm btn-success" level="W" onclick="savePlacements()">
<i class="bi bi-save"></i> 저장</button>
</div>
<div style="background:#e7f1ff; border:1px solid #0d6efd33; padding:8px 15px; margin-bottom:10px; border-radius:6px; font-size:12px; color:#31507a;">
드래그로 위치를 조절한 뒤 <strong>저장</strong> 버튼으로 일괄 반영합니다.
기본 항목([기본] 배지)은 [-] 시 <strong>미배치</strong>로만 이동하고, 커스텀 항목은 미배치 시 <strong>삭제</strong>됩니다.
저장 후 <strong>캐시 Reload</strong> 를 실행해야 포탈에 즉시 반영됩니다(미실행 시 TTL 경과 후 반영).
</div>
<div class="menu-board">
<div class="menu-lane-strip" id="laneStrip"><!-- 렌더링 --></div>
<div class="menu-lane unplaced">
<div class="lane-header">
<span class="lane-title">미배치</span>
<span class="badge text-bg-warning badge-src">보관함</span>
</div>
<ul class="lane-body" id="unplacedBody"><!-- 렌더링 --></ul>
</div>
</div>
<!-- 추가/수정 모달 -->
<div class="modal fade" id="menuModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">메뉴 추가</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기"></button>
</div>
<div class="modal-body">
<input type="hidden" id="modalMode" value="INSERT">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="fMenuId">메뉴 ID (kebab-case)</label>
<input type="text" class="form-control form-control-sm" id="fMenuId" placeholder="ex) custom-link">
</div>
<div class="col-md-6">
<label class="form-label" for="fMenuName">노출명</label>
<input type="text" class="form-control form-control-sm" id="fMenuName">
<div class="dflt-hint" id="hintMenuName"></div>
</div>
<div class="col-md-6">
<label class="form-label" for="fMenuPath">Path (그룹은 생략 가능)</label>
<input type="text" class="form-control form-control-sm" id="fMenuPath" placeholder="/example 또는 https://...">
<div class="dflt-hint" id="hintMenuPath"></div>
</div>
<div class="col-md-6">
<label class="form-label" for="fIconClass">아이콘 클래스 (마이페이지용, ex: fa-users)</label>
<input type="text" class="form-control form-control-sm" id="fIconClass">
</div>
<div class="col-md-4">
<label class="form-label" for="fMenuSection">섹션</label>
<select class="form-select form-select-sm" id="fMenuSection">
<option value="GNB">GNB (상단 메뉴)</option>
<option value="MYPAGE">마이페이지</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="fGroupYn">
<label class="form-check-label" for="fGroupYn">그룹(상위 메뉴)</label>
</div>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="fNewWindowYn">
<label class="form-check-label" for="fNewWindowYn">새 창 열기</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label">노출 권한 (미선택 = 전체 노출)</label>
<div class="role-check-group" id="exposeRoleChecks"></div>
<div class="dflt-hint" id="hintExposeRoles"></div>
</div>
<div class="col-md-6">
<label class="form-label">접근 권한 (미선택 = 제한 없음)</label>
<div class="role-check-group" id="accessRoleChecks"></div>
<div class="dflt-hint" id="hintAccessRoles"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-sm btn-primary" level="W" onclick="submitModal()">저장</button>
</div>
</div>
</div>
</div>
</div><!-- end content_middle -->
</div><!-- end right_box -->
</body>
</html>
@@ -19,16 +19,42 @@
var url_view = '<c:url value="/onl/apim/portalnotice/portalNoticeMan.view" />'; var url_view = '<c:url value="/onl/apim/portalnotice/portalNoticeMan.view" />';
var combo; var combo;
function formatNoticeType(cellvalue, options, rowObject) { // 코드 상수 — PortalNoticeUI 와 동기화
if (!combo || !combo.noticeTypeList) { var NOTICE_TYPE_INCIDENT = '3';
return cellvalue;
// IncidentState 라벨 — 상세 화면과 동일하게 유지
var STATE_LABELS = {
'INVESTIGATING': '조사중',
'IDENTIFIED': '원인 파악',
'MONITORING': '모니터링',
'RESOLVED': '해소',
'CANCELED': '취소'
};
function formatIncidentState(cellvalue, options, rowObject) {
if (!cellvalue) return '';
var label = STATE_LABELS[cellvalue] || cellvalue;
var closed = (cellvalue === 'RESOLVED' || cellvalue === 'CANCELED');
return closed
? '<span title="' + cellvalue + '">' + label + '</span>'
: '<span style="color: red;" title="' + cellvalue + '">' + label + '</span>';
} }
function formatNoticeType(cellvalue, options, rowObject) {
var name = cellvalue;
if (combo && combo.noticeTypeList) {
for (var i = 0; i < combo.noticeTypeList.length; i++) { for (var i = 0; i < combo.noticeTypeList.length; i++) {
if (combo.noticeTypeList[i].CODE == cellvalue) { if (combo.noticeTypeList[i].CODE == cellvalue) {
return combo.noticeTypeList[i].NAME; name = combo.noticeTypeList[i].NAME;
break;
} }
} }
return cellvalue; }
// 장애인데 종료 시각이 없으면 아직 진행 중이다
if (cellvalue == NOTICE_TYPE_INCIDENT && rowObject && !rowObject.endAt) {
return '<span style="color: red;">' + name + '-진행중</span>';
}
return name;
} }
function formatuseYn(cellvalue, options, rowObject) { function formatuseYn(cellvalue, options, rowObject) {
@@ -115,14 +141,15 @@
searchNoticeSubject: $('input[name=searchNoticeSubject]').val(), searchNoticeSubject: $('input[name=searchNoticeSubject]').val(),
searchNoticeDetail: $('input[name=searchNoticeDetail]').val() searchNoticeDetail: $('input[name=searchNoticeDetail]').val()
}, },
colNames:['id', 'No.', '게시유형', '제목', '고정여부', '사용', '마지막 수정일', '등록일', '등록자', '조회수'], colNames:['id', 'No.', '게시유형', '제목', '고정여부', '사용', '장애상태', '마지막 수정일', '등록일', '등록자', '조회수'],
colModel:[ colModel:[
{ name : 'id' , align:'center', key:true, hidden:true}, { name : 'id' , align:'center', key:true, hidden:true},
{ name : 'rowNum' , align:'center', width:60 }, { name : 'rowNum' , align:'center', width:60 },
{ name : 'noticeType' , align:'center', width:60, formatter: formatNoticeType }, { name : 'noticeType' , align:'center', width:100, formatter: formatNoticeType },
{ name : 'noticeSubject' , align:'left' , width:300, formatter: formatFile }, { name : 'noticeSubject' , align:'left' , width:300, formatter: formatFile },
{ name : 'fixYn' , align:'center', width:60, formatter: formatFixYn }, { name : 'fixYn' , align:'center', width:60, formatter: formatFixYn },
{ name : 'useYn' , align:'center', width:60, formatter: formatuseYn }, { name : 'useYn' , align:'center', width:60, formatter: formatuseYn },
{ name : 'state' , align:'center', width:90, sortable:false, formatter: formatIncidentState },
{ name : 'lastModifiedDate', align:'center', width:120 , formatter: timeStampFormat}, { name : 'lastModifiedDate', align:'center', width:120 , formatter: timeStampFormat},
{ name : 'createdDate' , align:'center', width:120, formatter: timeStampFormat }, { name : 'createdDate' , align:'center', width:120, formatter: timeStampFormat },
{ name : 'inquirerName' , align:'center', width:100 }, { name : 'inquirerName' , align:'center', width:100 },
@@ -32,10 +32,35 @@
// 영향 API 목록 (UI 상태) // 영향 API 목록 (UI 상태)
var affectedApis = []; var affectedApis = [];
// 장애 타임라인 (UI 상태)
var currentIncidentId = null;
var currentIncidentState = null;
// 종결 상태 — 재전이 불가 (재발은 새 장애로 등록)
var TERMINAL_STATES = ['RESOLVED', 'CANCELED'];
// IncidentState 라벨 — 상태 콤보 문구와 동일하게 유지
var STATE_LABELS = {
'INVESTIGATING': '조사중',
'IDENTIFIED': '원인 파악',
'MONITORING': '모니터링',
'RESOLVED': '해소',
'CANCELED': '취소'
};
function isIncidentType(t) { function isIncidentType(t) {
return t === NOTICE_TYPE_INCIDENT || t === NOTICE_TYPE_MAINTENANCE; return t === NOTICE_TYPE_INCIDENT || t === NOTICE_TYPE_MAINTENANCE;
} }
function stateLabel(code) {
if (!code) return '';
return (STATE_LABELS[code] || code) + ' (' + code + ')';
}
function escapeHtml(text) {
return $('<div>').text(text == null ? '' : text).html();
}
function init() { function init() {
var key = "${param.id}"; var key = "${param.id}";
isDetail = key != "" && key != "null"; isDetail = key != "" && key != "null";
@@ -76,12 +101,28 @@
// INCIDENT 만 상태 영역 표시 // INCIDENT 만 상태 영역 표시
if (t === NOTICE_TYPE_INCIDENT) { if (t === NOTICE_TYPE_INCIDENT) {
$('.incident-state-row').show(); $('.incident-state-row').show();
$('.incident-endat-hint').show();
} else { } else {
$('.incident-state-row').hide(); $('.incident-state-row').hide();
$('.incident-endat-hint').hide();
} }
// 장애는 종결(해소·취소) 후에만 종료 시각을 손볼 수 있다. 진행 중에는 상태를 따른다.
var closed = $.inArray(currentIncidentState, TERMINAL_STATES) >= 0;
$('#endAt').prop('readonly', t === NOTICE_TYPE_INCIDENT && !closed);
$('.incident-endat-hint').text(closed
? '※ 종결된 장애입니다. 실제 복구 시각으로 수정할 수 있습니다.'
: '※ 타임라인에서 해소·취소로 전이하면 자동 입력됩니다.');
} else { } else {
$('.incident-row').hide(); $('.incident-row').hide();
$('.incident-state-row').hide(); $('.incident-state-row').hide();
$('.incident-endat-hint').hide();
$('#endAt').prop('readonly', false);
}
// 타임라인은 등록된 장애(수정 모드)에서만 다룬다
if (t === NOTICE_TYPE_INCIDENT && isDetail && currentIncidentId) {
$('#timelineSection').show();
} else {
$('#timelineSection').hide();
} }
} }
@@ -192,6 +233,150 @@
renderAffectedApis(); renderAffectedApis();
} }
// ───────────────────── 장애 처리 타임라인 ─────────────────────
/** 현재 상태 기준으로 전이 가능한 선택지만 남긴다. 실제 차단은 서버가 한다. */
function applyStateChangeGuard() {
var terminal = $.inArray(currentIncidentState, TERMINAL_STATES) >= 0;
$('#timelineStateChange').prop('disabled', terminal);
if (terminal) {
$('#timelineStateChange').prop('checked', false);
$('#timelineNextState').prop('disabled', true).val('');
}
$('#timelineClosedHint').toggle(terminal);
$('#timelineResolveHint').toggle(!terminal);
// 현재와 동일한 상태로는 전이할 수 없다
$('#timelineNextState option').each(function() {
var v = $(this).val();
$(this).prop('disabled', v !== '' && v === currentIncidentState);
});
if ($('#timelineNextState').val() === currentIncidentState) {
$('#timelineNextState').val('');
}
}
function loadTimeline() {
if (!currentIncidentId) {
renderTimeline([]);
return;
}
$.ajax({
type: "POST", url: url, dataType: "json",
data: {cmd: 'TIMELINE_LIST', incidentId: currentIncidentId},
success: function (rows) { renderTimeline(rows || []); },
error: function (e) { alert(e.responseText); }
});
}
function renderTimeline(rows) {
var $tb = $('#timelineTbody');
$tb.empty();
if (!rows.length) {
$tb.append('<tr><td colspan="6" style="text-align:center;color:#999;">등록된 타임라인이 없습니다.</td></tr>');
return;
}
$.each(rows, function(_, row) {
var author = row.authorType === 'SYSTEM' ? 'System' : 'Admin';
var visible = row.visibleYn === 'Y';
var $tr = $('<tr>');
$tr.append('<td style="text-align:center;white-space:nowrap;">' + escapeHtml(row.eventAt) + '</td>');
$tr.append('<td style="text-align:center;">' + author + '</td>');
$tr.append('<td style="text-align:center;white-space:nowrap;">' + (row.stateAfter ? escapeHtml(stateLabel(row.stateAfter)) : '-') + '</td>');
$tr.append('<td>' + escapeHtml(row.body).replace(/\n/g, '<br>') + '</td>');
// 현재 상태(라벨) + 눌렀을 때 바뀔 상태(버튼) 를 나눠 표기 — 버튼 하나면 의미가 모호하다
var $visibleTd = $('<td style="text-align:center;white-space:nowrap;">');
$('<span>')
.text(visible ? '공개중' : '비공개')
.css({color: visible ? '#1B8C4A' : '#999', 'margin-right': '5px'})
.appendTo($visibleTd);
$('<button type="button" class="cssbtn" level="W" status="DETAIL" style="min-width:0;padding:3px 8px;">')
.text(visible ? '비공개로' : '공개로')
.click(function() { toggleTimelineVisible(row.timelineId, visible ? 'N' : 'Y'); })
.appendTo($visibleTd);
$tr.append($visibleTd);
var $deleteTd = $('<td style="text-align:center;">');
$('<button type="button" class="cssbtn smallBtn" level="W" status="DETAIL" style="min-width:0;width:90%;">')
.text('삭제')
.click(function() { deleteTimeline(row.timelineId); })
.appendTo($deleteTd);
$tr.append($deleteTd);
$tb.append($tr);
});
buttonControl(true);
}
function addTimeline() {
if (!currentIncidentId) {
alert('장애 정보를 먼저 저장하여 주십시오.');
return;
}
var body = $('#timelineBody').val();
if (!body || !$.trim(body)) {
alert('타임라인 메세지를 입력하여 주십시오.');
$('#timelineBody').focus();
return;
}
var stateChange = $('#timelineStateChange').is(':checked');
var nextState = $('#timelineNextState').val();
if (stateChange && !nextState) {
alert('변경할 상태를 선택하여 주십시오.');
return;
}
$.ajax({
type: "POST", url: url, dataType: "json",
data: {cmd: 'TIMELINE_ADD', incidentId: currentIncidentId, body: body,
stateChange: stateChange, nextState: nextState},
success: function (data) {
$('#timelineBody').val('');
$('#timelineStateChange').prop('checked', false).trigger('change');
// 상태 전이 시 서버가 상태/이전 상태/종료 시각을 함께 갱신한다
$('#stateText').text(data.state ? stateLabel(data.state) : '');
$('#previousState').text(data.previousState ? stateLabel(data.previousState) : '없음');
$('#endAt').val(data.endAt || '');
currentIncidentState = data.state || null;
applyStateChangeGuard();
// 종결 전이 직후부터 종료 시각을 손볼 수 있어야 한다
toggleIncidentFields();
loadTimeline();
},
error: function (e) { alert(e.responseText); }
});
}
function toggleTimelineVisible(timelineId, visibleYn) {
$.ajax({
type: "POST", url: url,
data: {cmd: 'TIMELINE_VISIBLE', timelineId: timelineId, visibleYn: visibleYn},
success: loadTimeline,
error: function (e) { alert(e.responseText); }
});
}
function deleteTimeline(timelineId) {
if (confirm("<%= localeMessage.getString("common.checkDelete")%>") != true) return;
$.ajax({
type: "POST", url: url,
data: {cmd: 'TIMELINE_DELETE', timelineId: timelineId},
success: loadTimeline,
error: function (e) { alert(e.responseText); }
});
}
/** 장애/점검 공지는 최초 게시 후 게시유형을 바꿀 수 없다. */
function lockNoticeType(noticeType) {
if (!isIncidentType(noticeType)) return;
$('#noticeType').prop('disabled', true);
if (!$('#noticeTypeHidden').length) {
$('#ajaxForm').append('<input type="hidden" id="noticeTypeHidden" name="noticeType">');
}
$('#noticeTypeHidden').val(noticeType);
}
function detail(key) { function detail(key) {
if (!isDetail) return; if (!isDetail) return;
$.ajax({ $.ajax({
@@ -207,17 +392,28 @@
var decodedContent = decodeHTMLEntities(data.noticeDetail); var decodedContent = decodeHTMLEntities(data.noticeDetail);
$('#contents').summernote('code', decodedContent); $('#contents').summernote('code', decodedContent);
// 게시유형은 최초 저장 후 변경 불가 (disabled 는 전송되지 않아 hidden 으로 값을 유지)
lockNoticeType(data.noticeType);
// 장애/점검 필드 // 장애/점검 필드
$('#summary').val(data.summary || ''); $('#summary').val(data.summary || '');
$('#startedAt').val(data.startedAt || ''); $('#startedAt').val(data.startedAt || '');
$('#endAt').val(data.endAt || ''); $('#endAt').val(data.endAt || '');
$('#state').val(data.state || 'INVESTIGATING'); // 상태/이전 상태는 타임라인 게시로만 바뀐다 (직접 수정 불가)
$('#previousState').text(data.previousState || '없음'); $('#stateText').text(data.state ? stateLabel(data.state) : '');
$('#previousState').text(data.previousState ? stateLabel(data.previousState) : '없음');
affectedApis = (data.affectedApis || []).map(function(a){ affectedApis = (data.affectedApis || []).map(function(a){
return {apiId: a.apiId, apiName: a.apiName || ''}; return {apiId: a.apiId, apiName: a.apiName || ''};
}); });
renderAffectedApis(); renderAffectedApis();
currentIncidentId = data.incidentId || null;
currentIncidentState = data.state || null;
applyStateChangeGuard();
toggleIncidentFields(); toggleIncidentFields();
if (data.noticeType === NOTICE_TYPE_INCIDENT && currentIncidentId) {
loadTimeline();
}
if (data.fileInfo) { if (data.fileInfo) {
fileInfo = { fileInfo = {
@@ -245,6 +441,13 @@
$('#btn_addAffectedApi').click(openApiPopup); $('#btn_addAffectedApi').click(openApiPopup);
$('#btn_removeAffectedApi').click(removeSelectedAffectedApis); $('#btn_removeAffectedApi').click(removeSelectedAffectedApis);
$('#timelineStateChange').on('change', function() {
var checked = $(this).is(':checked');
$('#timelineNextState').prop('disabled', !checked);
if (!checked) $('#timelineNextState').val('');
});
$('#btn_addTimeline').click(addTimeline);
$('#fileInput').change(function(e) { $('#fileInput').change(function(e) {
var file = e.target.files[0]; var file = e.target.files[0];
if (file) { if (file) {
@@ -273,6 +476,11 @@
alert('점검 종료 시각을 입력해 주십시오.'); alert('점검 종료 시각을 입력해 주십시오.');
return; return;
} }
// datetime-local 값은 'YYYY-MM-DDTHH:mm' 이라 문자열 비교로 선후가 가려진다
if ($('#endAt').val() && $('#endAt').val() < $('#startedAt').val()) {
alert('종료 시각은 시작 시각보다 빠를 수 없습니다.');
return;
}
if (affectedApis.length === 0) { if (affectedApis.length === 0) {
alert('영향 API를 1건 이상 선택해 주십시오.'); alert('영향 API를 1건 이상 선택해 주십시오.');
return; return;
@@ -394,21 +602,15 @@
<th>종료</th> <th>종료</th>
<td> <td>
<input type="datetime-local" id="endAt" name="endAt" style="width:90%" placeholder="해소시 자동 채움"> <input type="datetime-local" id="endAt" name="endAt" style="width:90%" placeholder="해소시 자동 채움">
</td> <div class="incident-endat-hint" style="display:none;color:#888;font-size:12px;margin-top:3px;">
</tr> ※ 타임라인에서 해소·취소로 전이하면 자동 입력됩니다.
<tr class="incident-row incident-state-row" style="display:none;">
<th>상태</th>
<td>
<div class="select-style">
<select id="state" name="state">
<option value="INVESTIGATING">INVESTIGATING - 조사중</option>
<option value="IDENTIFIED">IDENTIFIED - 원인 파악</option>
<option value="MONITORING">MONITORING - 모니터링</option>
<option value="RESOLVED">RESOLVED - 해소</option>
<option value="CANCELED">CANCELED - 취소</option>
</select>
</div> </div>
</td> </td>
</tr>
<!-- 상태/이전 상태는 타임라인 게시로만 전이한다 (직접 수정 불가) -->
<tr class="incident-row incident-state-row" style="display:none;">
<th>상태</th>
<td><span id="stateText"></span></td>
<th>이전 상태</th> <th>이전 상태</th>
<td><span id="previousState">없음</span></td> <td><span id="previousState">없음</span></td>
</tr> </tr>
@@ -435,6 +637,61 @@
</tr> </tr>
</table> </table>
</form> </form>
<!-- 타임라인은 [수정] 저장과 별개로 게시 즉시 반영되므로 폼 밖에 둔다 -->
<div id="timelineSection" style="display:none;margin-top:20px;">
<div class="title">타임라인</div>
<table class="table_row" cellspacing="0" style="width:100%;">
<colgroup>
<col style="width: 130px"/>
<col style="width: 80px"/>
<col style="width: 140px"/>
<col/>
<col style="width: 160px"/>
<col style="width: 80px"/>
</colgroup>
<thead>
<tr>
<th>시간</th>
<th>작성자</th>
<th>상태</th>
<th>메세지</th>
<th>개발자포탈 공개</th>
<th>삭제</th>
</tr>
</thead>
<tbody id="timelineTbody">
<tr><td colspan="6" style="text-align:center;color:#999;">등록된 타임라인이 없습니다.</td></tr>
</tbody>
</table>
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;">
<label>
<input type="checkbox" id="timelineStateChange"> 상태 변경
</label>
<div class="select-style" style="width:220px;">
<select id="timelineNextState" disabled>
<option value="">(현재 상태 유지)</option>
<option value="INVESTIGATING">INVESTIGATING - 조사중</option>
<option value="IDENTIFIED">IDENTIFIED - 원인 파악</option>
<option value="MONITORING">MONITORING - 모니터링</option>
<option value="RESOLVED">RESOLVED - 해소</option>
<option value="CANCELED">CANCELED - 취소</option>
</select>
</div>
<span id="timelineResolveHint" style="color:#888;font-size:12px;">※ 해소(RESOLVED)·취소(CANCELED)로 전이하면 종료 시각이 자동 입력됩니다.</span>
<span id="timelineClosedHint" style="display:none;color:#C0392B;font-size:12px;">
※ 종결된 장애입니다. 상태 변경 불가 — 재발 시 새 장애로 등록하십시오.
</span>
</div>
<div style="margin-top:5px;display:flex;gap:5px;">
<textarea id="timelineBody" style="flex:1;height:60px;"
placeholder="타임라인 메세지 입력&#10;예 : 모니터링 5분간 정상 응답 확인 후 완료 상태 전이 예정"></textarea>
<button type="button" class="cssbtn" id="btn_addTimeline" level="W" status="DETAIL">
<i class="material-icons">send</i> 게시
</button>
</div>
</div>
</div> </div>
</div> </div>
</body> </body>
@@ -312,7 +312,9 @@ $(document).ready(function() {
<div class="content_middle" id="content_middle"> <div class="content_middle" id="content_middle">
<div class="search_wrap"> <div class="search_wrap">
<button type="button" class="cssbtn" id="btn_new" level="W"><i class="material-icons">add</i> <%= localeMessage.getString("button.new") %></button> <button type="button" class="cssbtn" id="btn_new" level="W"><i class="material-icons">add</i> <%= localeMessage.getString("button.new") %></button>
<c:if test="${hardDeleteEnabled}">
<button type="button" class="cssbtn" id="btn_delete_selected" level="W" style="background-color: #dc3545; border-color: #dc3545; color: white;"><i class="material-icons">delete_forever</i> 선택 삭제</button> <button type="button" class="cssbtn" id="btn_delete_selected" level="W" style="background-color: #dc3545; border-color: #dc3545; color: white;"><i class="material-icons">delete_forever</i> 선택 삭제</button>
</c:if>
<button type="button" class="cssbtn" id="btn_search" level="R"><i class="material-icons">search</i> <%= localeMessage.getString("button.search") %></button> <button type="button" class="cssbtn" id="btn_search" level="R"><i class="material-icons">search</i> <%= localeMessage.getString("button.search") %></button>
</div> </div>
<%--법인정보관리--%> <%--법인정보관리--%>
@@ -11,7 +11,7 @@
response.setHeader("Expires", "0"); response.setHeader("Expires", "0");
MonitoringContext monitoringContext = (MonitoringContext)CommonUtil.getBean(request, "monitoringContext"); MonitoringContext monitoringContext = (MonitoringContext)CommonUtil.getBean(request, "monitoringContext");
String reverseProxyUrl = monitoringContext.getStringProperty("djb.reverse_proxy.url", ""); String reverseProxyUrl = monitoringContext.getStringProperty(MonitoringContext.RMS_WEBHOOK_REVERSE_PROXY_URL, "");
%> %>
<%! <%!
public String getRequiredLabel(String label) { public String getRequiredLabel(String label) {
@@ -138,7 +138,11 @@
$("#btn_delete").hide(); $("#btn_delete").hide();
} }
$("#approvalStatus").val(portalOrgData.approvalStatus); $("select[name=approvalStatus]").val(portalOrgData.approvalStatus);
// 승인상태는 읽기전용(수정 불가) - 값은 그대로 제출되도록 disabled 대신 잠금 처리
$("select[name=approvalStatus]")
.css({'pointer-events':'none', 'background-color':'#eee'})
.attr('tabindex', '-1');
$("#compRegNo").val(formatCompanyRegNo(portalOrgData.compRegNo)); $("#compRegNo").val(formatCompanyRegNo(portalOrgData.compRegNo));
$("#corpRegNo").val(formatCorpRegNo(portalOrgData.corpRegNo)); $("#corpRegNo").val(formatCorpRegNo(portalOrgData.corpRegNo));
$("#createdDate").inputmask("9999-99-99 99:99:99", {'autoUnmask': true}); $("#createdDate").inputmask("9999-99-99 99:99:99", {'autoUnmask': true});
@@ -480,8 +484,7 @@
} }
} }
if (confirm("<%= localeMessage.getString("common.checkSave")%>") !== true) // 저장 확인/사유 입력은 유효성 검사 및 FormData 구성 후 처리 (하단 submit 분기)
return;
// FormData 생성 전에 분리된 전화번호 필드 제거 // FormData 생성 전에 분리된 전화번호 필드 제거
$("#scPhonePrefix, #scPhoneMiddle, #scPhoneLast, #orgPhonePrefix, #orgPhoneMiddle, #orgPhoneLast").removeAttr('name'); $("#scPhonePrefix, #scPhoneMiddle, #scPhoneLast, #orgPhonePrefix, #orgPhoneMiddle, #orgPhoneLast").removeAttr('name');
@@ -516,8 +519,14 @@
} }
// cmd 파라미터 추가 // cmd 및 사유(auditReason) 첨부 후 전송
formData.append("cmd", isDetail ? "UPDATE" : "INSERT"); function submitOrg(cmd, auditReason) {
formData.append("cmd", cmd);
// 감사로그(AUDIT_LOG) 발화를 위한 systemCode. 법인/가입자 리포지토리는 고정 EMS 스키마라 스키마 전환 영향 없음
formData.append("serviceType", "APIGW");
if (auditReason) {
formData.append("auditReason", auditReason);
}
$.ajax({ $.ajax({
type : "POST", type : "POST",
@@ -533,6 +542,24 @@
alert(e.responseText); alert(e.responseText);
} }
}); });
}
if (isDetail) {
// 수정: 상태를 REMOVED로 바꾼 경우 삭제(cmd=DELETE)로 감사 기록 + 익명화
var targetCmd = ($("#orgStatus").val() === 'REMOVED') ? "DELETE" : "UPDATE";
var isRemove = (targetCmd === "DELETE");
showReasonPrompt(
isRemove ? "해당 법인을 삭제(탈퇴) 처리합니다. 사유를 입력해 주세요." : "법인 정보를 수정합니다. 사유를 입력해 주세요.",
{
title: isRemove ? "삭제 사유" : "수정 사유",
onConfirm: function(reason) { submitOrg(targetCmd, reason); }
}
);
} else {
// 신규 등록: 사유 불필요
if (confirm("<%= localeMessage.getString("common.checkSave")%>") !== true) return;
submitOrg("INSERT", null);
}
}); });
$("#btn_previous").click(function() { $("#btn_previous").click(function() {
@@ -541,9 +568,13 @@
$("#btn_delete").click(function(){ $("#btn_delete").click(function(){
if(confirm("<%= localeMessage.getString("common.confirmMsg")%>")){ showReasonPrompt("해당 법인을 삭제 처리합니다. 사유를 입력해 주세요.", {
title: "삭제 사유",
onConfirm: function(reason) {
var postData = $('#ajaxForm').serializeArray(); var postData = $('#ajaxForm').serializeArray();
postData.push({name: "cmd" , value:"DELETE"}); postData.push({name: "cmd" , value:"DELETE"});
postData.push({name: "serviceType", value: "APIGW"});
postData.push({name: "auditReason", value: reason});
$.ajax({ $.ajax({
type : "POST", type : "POST",
@@ -557,9 +588,8 @@
alert(e.responseText); alert(e.responseText);
} }
}); });
}else{
return false;
} }
});
}); });
@@ -20,6 +20,7 @@
var url = '<c:url value="/onl/apim/portalproperty/propertyMan.json" />'; var url = '<c:url value="/onl/apim/portalproperty/propertyMan.json" />';
var url_view = '<c:url value="/onl/apim/portalproperty/propertyMan.view" />'; var url_view = '<c:url value="/onl/apim/portalproperty/propertyMan.view" />';
var lastsel2; var lastsel2;
var clickedCol; //클릭한 셀의 컬럼 인덱스(포커스 이동용)
function isValid() { function isValid() {
if ($('input[name=prptyGroupName]').val() == "") { if ($('input[name=prptyGroupName]').val() == "") {
@@ -103,6 +104,10 @@
}, },
loadComplete: function () { loadComplete: function () {
}, },
onCellSelect: function (rowid, iCol) {
//onSelectRow보다 먼저 발화 → 클릭한 컬럼 인덱스 저장
clickedCol = iCol;
},
onSelectRow: function (rowid, status) { onSelectRow: function (rowid, status) {
if (lastsel2 != undefined) { if (lastsel2 != undefined) {
if ($("#grid tr#" + lastsel2).attr("editable") == 1) { //editable=1 means row in edit mode if ($("#grid tr#" + lastsel2).attr("editable") == 1) { //editable=1 means row in edit mode
@@ -110,7 +115,8 @@
} }
} }
$('#grid').restoreRow(lastsel2); $('#grid').restoreRow(lastsel2);
$('#grid').editRow(rowid, true); //focusField로 클릭한 셀에 포커스(미지정 시 첫 편집 컬럼으로 감)
$('#grid').editRow(rowid, {keys: true, focusField: clickedCol});
lastsel2 = rowid; lastsel2 = rowid;
}, },
onSortCol: function () { onSortCol: function () {
@@ -347,9 +347,11 @@
<button type="button" class="cssbtn" id="btn_new" level="W"><i <button type="button" class="cssbtn" id="btn_new" level="W"><i
class="material-icons">add</i> <%= localeMessage.getString("button.new") %> class="material-icons">add</i> <%= localeMessage.getString("button.new") %>
</button> </button>
<c:if test="${hardDeleteEnabled}">
<button type="button" class="cssbtn" id="btn_delete_selected" level="W" style="background-color: #dc3545; border-color: #dc3545; color: white;"><i <button type="button" class="cssbtn" id="btn_delete_selected" level="W" style="background-color: #dc3545; border-color: #dc3545; color: white;"><i
class="material-icons">delete_forever</i> 선택 삭제 class="material-icons">delete_forever</i> 선택 삭제
</button> </button>
</c:if>
<button type="button" class="cssbtn" id="btn_search" level="R"><i <button type="button" class="cssbtn" id="btn_search" level="R"><i
class="material-icons">search</i> <%= localeMessage.getString("button.search") %> class="material-icons">search</i> <%= localeMessage.getString("button.search") %>
</button> </button>
@@ -165,6 +165,10 @@
$("#userStatus").val(data.userStatus); $("#userStatus").val(data.userStatus);
$("#roleCode").val(data.roleCode).trigger('change'); $("#roleCode").val(data.roleCode).trigger('change');
$("#approvalStatus").val(data.approvalStatus); $("#approvalStatus").val(data.approvalStatus);
// 승인상태는 읽기전용(수정 불가) - 값은 그대로 제출되도록 disabled 대신 잠금 처리
$("#approvalStatus")
.css({'pointer-events':'none', 'background-color':'#eee'})
.attr('tabindex', '-1');
// 탈퇴 상태 처리 // 탈퇴 상태 처리
if (originalUserStatus === STATUS.REMOVED) { if (originalUserStatus === STATUS.REMOVED) {
@@ -281,7 +285,7 @@
} }
const fullLoginId = id + "@" + domain; const fullLoginId = id + "@" + domain;
const emailPattern = /^[A-Za-z0-9._-]*@[A-Za-z0-9][-A-Za-z0-9.]+\.[A-Za-z]{2,}$/; const emailPattern = /^[A-Za-z0-9._%+-]*@[A-Za-z0-9][-A-Za-z0-9.]+\.[A-Za-z]{2,}$/;
if (!emailPattern.test(fullLoginId)) { if (!emailPattern.test(fullLoginId)) {
alert(fullLoginId + " 은 올바른 이메일 형식이 아닙니다."); alert(fullLoginId + " 은 올바른 이메일 형식이 아닙니다.");
return; return;
@@ -313,7 +317,7 @@
} }
function validateEmailId(input) { function validateEmailId(input) {
input.value = input.value.replace(/[^A-Za-z0-9._-]/g, ''); input.value = input.value.replace(/[^A-Za-z0-9._%+-]/g, '');
} }
function validateDomainInput(input) { function validateDomainInput(input) {
@@ -488,12 +492,14 @@
formData.push({name: 'mobileNumber', value: mobileNumber}); formData.push({name: 'mobileNumber', value: mobileNumber});
} }
formData.push({ // cmd 및 사유(auditReason) 첨부 후 전송
name: 'cmd', function submitUser(cmd, auditReason) {
value: isDetail ? "UPDATE" : "INSERT" formData.push({ name: 'cmd', value: cmd });
}); // 감사로그(AUDIT_LOG) 발화를 위한 systemCode. 가입자 리포지토리는 고정 EMS 스키마라 스키마 전환 영향 없음
formData.push({ name: 'serviceType', value: 'APIGW' });
if (!confirm("<%= localeMessage.getString("common.checkSave")%>")) return; if (auditReason) {
formData.push({ name: 'auditReason', value: auditReason });
}
$.ajax({ $.ajax({
type: "POST", type: "POST",
@@ -511,6 +517,24 @@
alert(e.responseText); alert(e.responseText);
} }
}); });
}
if (isDetail) {
// 수정: 상태를 REMOVED로 바꾼 경우 삭제(cmd=DELETE)로 감사 기록 + 익명화
var targetCmd = ($("#userStatus").val() === 'REMOVED') ? "DELETE" : "UPDATE";
var isRemove = (targetCmd === "DELETE");
showReasonPrompt(
isRemove ? "해당 가입자를 삭제(탈퇴) 처리합니다. 사유를 입력해 주세요." : "가입자 정보를 수정합니다. 사유를 입력해 주세요.",
{
title: isRemove ? "삭제 사유" : "수정 사유",
onConfirm: function(reason) { submitUser(targetCmd, reason); }
}
);
} else {
// 신규 등록: 사유 불필요. 초기 패스워드는 비밀번호 초기화와 동일(이메일ID + subfix)
if (!confirm("가입자를 등록하시겠습니까?\n초기 패스워드는 '이메일ID${passwordInitSubfix}' 입니다.")) return;
submitUser("INSERT", null);
}
}); });
$("#btn_previous").click(function () { $("#btn_previous").click(function () {
@@ -518,14 +542,17 @@
}); });
$("#btn_delete").click(function () { $("#btn_delete").click(function () {
if (!confirm("<%= localeMessage.getString("common.confirmMsg")%>")) return; showReasonPrompt("해당 가입자를 삭제 처리합니다. 사유를 입력해 주세요.", {
title: "삭제 사유",
onConfirm: function(reason) {
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: url, url: url,
data: { data: {
cmd: "DELETE", cmd: "DELETE",
id: $('input[name=id]').val() id: $('input[name=id]').val(),
serviceType: "APIGW",
auditReason: reason
}, },
success: function () { success: function () {
alert("<%= localeMessage.getString("common.deleteMsg") %>"); alert("<%= localeMessage.getString("common.deleteMsg") %>");
@@ -535,10 +562,12 @@
alert(e.responseText); alert(e.responseText);
} }
}); });
}
});
}); });
$("#btn_password_reset").click(function () { $("#btn_password_reset").click(function () {
if (!confirm("패스워드를 초기화 하시겠습니까?\n초기 패스워드는 '!' + 이메일ID 입니다.")) return; if (!confirm("패스워드를 초기화 하시겠습니까?\n초기 패스워드는 '이메일ID${passwordInitSubfix}' 입니다.")) return;
$.ajax({ $.ajax({
type: "POST", type: "POST",
@@ -172,6 +172,9 @@ $(document).ready(function() {
<form id="ajaxForm"> <form id="ajaxForm">
<input type="hidden" name="id"> <input type="hidden" name="id">
<table class="table_row" cellspacing="0"> <table class="table_row" cellspacing="0">
<colgroup>
<col width="200px">
</colgroup>
<tr> <tr>
<th><%= localeMessage.getString("messageTemplate.messageCode") %> <font color="red"> *</font></th> <th><%= localeMessage.getString("messageTemplate.messageCode") %> <font color="red"> *</font></th>
<td><input type="text" name="messageCode" data-required data-warning="메세지 코드는 필수입니다"/></td> <td><input type="text" name="messageCode" data-required data-warning="메세지 코드는 필수입니다"/></td>
@@ -0,0 +1,242 @@
<%@ page language="java" contentType="text/html; charset=utf-8" %>
<%@ page import="java.io.*" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
%>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<jsp:include page="/jsp/common/include/script.jsp"/>
<style>
.url-cell { display:inline-block; max-width:380px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; vertical-align:middle; }
.succ-y { color:#1a73e8; font-weight:bold; }
.succ-n { color:#d93025; font-weight:bold; }
</style>
<script language="javascript">
var url = '<c:url value="/onl/apim/webhook/webhookSendLogMan.json" />';
var url_view = '<c:url value="/onl/apim/webhook/webhookSendLogMan.view" />';
var combo;
function escapeHtmlJs(s) { return $('<div>').text(s == null ? '' : s).html(); }
function escapeAttr(s) { return escapeHtmlJs(s).replace(/"/g, '&quot;'); }
function formatTargetUrl(cellvalue, options, rowObject) {
var v = cellvalue || '';
return '<span class="url-cell" title="' + escapeAttr(v) + '">' + escapeHtmlJs(v) + '</span>';
}
// 이벤트유형 코드 → 모니터링 공통코드(EVENT_TYPE) 코드명
function formatEventType(cellvalue, options, rowObject) {
if (!combo || !combo.eventTypeList) {
return escapeHtmlJs(cellvalue);
}
for (var i = 0; i < combo.eventTypeList.length; i++) {
if (combo.eventTypeList[i].CODE == cellvalue) {
return escapeHtmlJs(combo.eventTypeList[i].NAME);
}
}
return escapeHtmlJs(cellvalue);
}
function formatSuccess(cellvalue, options, rowObject) {
if (cellvalue === true) return '<span class="succ-y">성공</span>';
if (cellvalue === false) return '<span class="succ-n">실패</span>';
return '';
}
function init() {
$("#startDatepicker").datepicker().inputmask("yyyy-mm-dd",{'autoUnmask':true});
$("#endDatepicker").datepicker().inputmask("yyyy-mm-dd",{'autoUnmask':true});
var today = getToday();
$("input[name=searchStartYYYYMMDD]").val(today.substring(0,6)+"01");
$("input[name=searchEndYYYYMMDD]").val(today);
$("input[name=searchStartDate]").val(today.substring(0,6)+"01");
$("input[name=searchEndDate]").val(today);
$.ajax({
type: "POST", url: url, dataType: "json",
data: {cmd: 'LIST_INIT_COMBO'},
success: function (json) {
combo = json;
new makeOptions("CODE", "NAME").setObj($("select[name=searchEventType]"))
.setNoValueInclude(true).setNoValue("", "<%=localeMessage.getString("combo.all")%>")
.setData(json.eventTypeList).rendering();
// 뒤로가기 등으로 이전 검색조건 파라미터가 있으면 기본값을 덮어씀
putSelectFromParam();
// 콤보(이벤트유형) 로드 후 그리드 생성 — formatEventType 경합 방지
buildGrid();
},
error: function (e) { alert(e.responseText); }
});
}
function buildGrid() {
$('#grid').jqGrid({
datatype: "json",
mtype: 'POST',
url: url,
postData: getSearchForJqgrid("cmd", "LIST"),
colNames: ['id', 'No.', '기관명', '이벤트유형', 'Target URL', 'Proxy URL', 'Status', '성공여부', '발송서버', '발송일시'],
colModel: [
{name: 'id', align: 'center', key: true, hidden: true},
{name: 'rowNum', align: 'center', width: 45, sortable: false},
{name: 'orgName', align: 'left', width: 140},
{name: 'eventType', align: 'center', width: 120, formatter: formatEventType},
{name: 'targetUrl', align: 'left', width: 300, formatter: formatTargetUrl},
{name: 'proxyUrl', align: 'left', width: 300, formatter: formatTargetUrl},
{name: 'statusCode', align: 'center', width: 60},
{name: 'success', align: 'center', width: 70, formatter: formatSuccess},
{name: 'sendBy', align: 'center', width: 100},
{name: 'sentAt', align: 'center', width: 140}
],
jsonReader: {repeatitems: false},
pager: $('#pager'),
page: '${param.page}',
rowNum: '${rmsDefaultRowNum}',
autoheight: true,
height: $("#container").height(),
autowidth: true,
viewrecords: true,
rowList: eval('[${rmsDefaultRowList}]'),
ondblClickRow: function (rowId) {
var rowData = $(this).getRowData(rowId);
var id = rowData['id'];
var url2 = url_view + '?cmd=DETAIL';
url2 += '&page=' + $(this).getGridParam("page");
url2 += '&returnUrl=' + getReturnUrl();
url2 += '&menuId=' + '${param.menuId}';
url2 += '&id=' + id;
url2 += '&' + getSearchUrl();
goNav(url2);
},
loadComplete: function () {
var page = $(this).getGridParam('page');
var rowNum = $(this).getGridParam('rowNum');
var rows = $(this).getDataIDs();
var colModel = $(this).getGridParam("colModel");
for (var i = 0; i < rows.length; i++) {
var number = ((page - 1) * rowNum + i) + 1;
$(this).setCell(rows[i], 'rowNum', number);
}
// 서버 고정 정렬(등록일시 역순) — 컬럼 정렬 비활성
for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, {sortable: false});
}
},
loadError: function (jqXHR, textStatus, errorThrown) {
var location = '<%=request.getContextPath()%>/';
comloadError(jqXHR, textStatus, errorThrown, location);
}
});
resizeJqGridWidth('grid', 'content_middle', '1000');
}
$(document).ready(function () {
init();
$("#btn_search").click(function () {
var start = $("input[name=searchStartYYYYMMDD]").val().replace(/-/gi, "");
var end = $("input[name=searchEndYYYYMMDD]").val().replace(/-/gi, "");
if (start && start > getToday()) {
alert("시작일이 오늘 이후입니다. 시작일을 확인해주세요.");
$("input[name=searchStartYYYYMMDD]").focus();
return false;
}
if (start && end && start > end) {
alert("조회기간 종료일은 시작일보다 커야합니다.");
$("input[name=searchEndYYYYMMDD]").focus();
return false;
}
$("input[name=searchStartDate]").val(start);
$("input[name=searchEndDate]").val(end);
var postData = getSearchForJqgrid("cmd", "LIST");
$("#grid").setGridParam({url: url, postData: postData, page: 1}).trigger("reloadGrid");
});
$("select[name^=search]").change(function () { $("#btn_search").click(); });
$("input[name^=search]").keydown(function (key) {
if (key.keyCode == 13) { $("#btn_search").click(); }
});
buttonControl();
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path"><li><a href="#">${rmsMenuPath}</a></li></ul>
</div><!-- end content_top -->
<div class="content_middle" id="content_middle">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_search" level="R"><i class="material-icons">search</i> <%= localeMessage.getString("button.search") %></button>
</div>
<div class="title">웹훅 발송 로그<span class="tooltip">외부로 발송된 웹훅 이력을 조회합니다.</span></div>
<form id="ajaxForm" onsubmit="return false;">
<table class="search_condition" cellspacing=0;>
<colgroup>
<col style="width:120px;"><col style="width:200px;">
<col style="width:120px;"><col style="width:200px;">
<col style="width:120px;"><col style="width:200px;">
</colgroup>
<tbody>
<tr>
<th>기관명</th>
<td><input type="text" name="searchOrgName" autocomplete="off"></td>
<th>이벤트유형</th>
<td>
<div class="select-style">
<select name="searchEventType"></select>
</div>
</td>
<th>성공여부</th>
<td>
<div class="select-style">
<select name="searchSuccess">
<option value="">전체</option>
<option value="Y">성공</option>
<option value="N">실패</option>
</select>
</div>
</td>
</tr>
<tr>
<th>Target URL</th>
<td><input type="text" name="searchTargetUrl" autocomplete="off" style="width:95%;"></td>
<th>발송기간</th>
<td>
<input type="text" name="searchStartYYYYMMDD" id="startDatepicker" readonly="readonly" value="" size="10" style="width:40%; border:1px solid #ebebec;">
~
<input type="text" name="searchEndYYYYMMDD" value="" id="endDatepicker" size="10" readonly="readonly" style="width:40%; border:1px solid #ebebec;">
<input type="hidden" name="searchStartDate" value="">
<input type="hidden" name="searchEndDate" value="">
</td>
<th></th>
<td></td>
</tr>
</tbody>
</table>
</form>
<table id="grid"></table>
<div id="pager"></div>
</div><!-- end content_middle -->
</div><!-- end right_box -->
</body>
</html>
@@ -0,0 +1,137 @@
<%@ page language="java" contentType="text/html; charset=utf-8" %>
<%@ page import="java.io.*" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
%>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<jsp:include page="/jsp/common/include/script.jsp"/>
<style>
.succ-y { color:#1a73e8; font-weight:bold; }
.succ-n { color:#d93025; font-weight:bold; }
.pre-wrap { white-space:pre-wrap; word-break:break-all; min-height:60px; line-height:1.5; }
</style>
<script language="javascript">
var url = '<c:url value="/onl/apim/webhook/webhookSendLogMan.json" />';
var url_view = '<c:url value="/onl/apim/webhook/webhookSendLogMan.view" />';
var key = '${param.id}';
function escapeHtmlJs(s) { return $('<div>').text(s == null ? '' : s).html(); }
function detail() {
if (!key) return;
$.ajax({
type: "POST", url: url, dataType: "json",
data: {cmd: 'DETAIL', id: key},
success: function (data) {
$('#id').text(data.id != null ? data.id : '');
$('#orgName').text(data.orgName || '');
$('#eventType').text(data.eventTypeName || data.eventType || '')
.attr('title', data.eventType || '');
$('#targetUrl').text(data.targetUrl || '');
$('#proxyUrl').text(data.proxyUrl || '');
$('#statusCode').text(data.statusCode != null ? data.statusCode : '');
$('#retryCount').text(data.retryCount != null ? data.retryCount : '');
$('#sendBy').text(data.sendBy || '');
$('#sentAt').text(data.sentAt || '');
$('#signature').text(data.signature || '');
$('#errorMessage').text(data.errorMessage || '');
$('#payload').text(data.payload || '');
$('#responseBody').text(data.responseBody || '');
if (data.success === true) {
$('#success').html('<span class="succ-y">성공</span>');
} else if (data.success === false) {
$('#success').html('<span class="succ-n">실패</span>');
} else {
$('#success').text('');
}
},
error: function (e) { alert(e.responseText); }
});
}
$(document).ready(function () {
var returnUrl = getReturnUrlForReturn();
buttonControl();
detail();
$('#btn_previous').click(function () { goNav(returnUrl); });
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path"><li><a href="#">${rmsMenuPath}</a></li></ul>
</div>
<div class="content_middle">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_previous" level="R" status="DETAIL,NEW"><i class="material-icons">arrow_back</i> <%= localeMessage.getString("button.previous") %></button>
</div>
<div class="title" id="title">웹훅 발송 로그 상세</div>
<table class="table_row" cellspacing="0">
<colgroup>
<col style="width:12%"/><col style="width:38%"/>
<col style="width:12%"/><col style="width:38%"/>
</colgroup>
<tr>
<th>ID</th>
<td><span id="id"></span></td>
<th>기관명</th>
<td><span id="orgName"></span></td>
</tr>
<tr>
<th>이벤트유형</th>
<td><span id="eventType"></span></td>
<th>Status Code</th>
<td><span id="statusCode"></span></td>
</tr>
<tr>
<th>Target URL</th>
<td><span id="targetUrl"></span></td>
<th>Proxy URL</th>
<td><span id="proxyUrl"></span></td>
</tr>
<tr>
<th>성공여부</th>
<td><span id="success"></span></td>
<th>재시도횟수</th>
<td><span id="retryCount"></span></td>
</tr>
<tr>
<th>발송서버</th>
<td><span id="sendBy"></span></td>
<th>발송일시</th>
<td><span id="sentAt"></span></td>
</tr>
<tr>
<th>Signature</th>
<td colspan="3"><span id="signature"></span></td>
</tr>
<tr>
<th>Payload</th>
<td colspan="3"><div id="payload" class="pre-wrap"></div></td>
</tr>
<tr>
<th>Response Body</th>
<td colspan="3"><div id="responseBody" class="pre-wrap"></div></td>
</tr>
<tr>
<th>에러 메세지</th>
<td colspan="3"><div id="errorMessage" class="pre-wrap"></div></td>
</tr>
</table>
</div>
</div>
</body>
</html>
@@ -66,22 +66,27 @@
}, },
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: '{b}: {c} ({d}%)' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString() + ' (' + params.percent + '%)';
}
}, },
legend: { legend: {
orient: 'horizontal', orient: 'vertical',
bottom: 10, right: 30,
top: 'middle',
data: ['성공', 'Timeout', '시스템오류'] data: ['성공', 'Timeout', '시스템오류']
}, },
series: [{ series: [{
name: '총건수', name: '총건수',
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['38%', '65%'],
center: ['50%', '50%'], center: ['44%', '55%'],
avoidLabelOverlap: true, avoidLabelOverlap: true,
label: { label: {
show: true, show: true,
formatter: '{b}: {c}' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString();
}
}, },
emphasis: { emphasis: {
label: { label: {
@@ -98,18 +103,6 @@
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } }, { value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } } { value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } }
] ]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}] }]
}; };
@@ -119,7 +112,7 @@
// 호출량 차트 // 호출량 차트
var callOption = { var callOption = {
title: { text: '호출량 추이', left: 'center', textStyle: { fontSize: 14 } }, title: { text: '호출량 추이', left: 'center', top: 10, textStyle: { fontSize: 14 } },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
@@ -191,13 +184,9 @@
{ value: totalTimeout, name: 'Timeout' }, { value: totalTimeout, name: 'Timeout' },
{ value: totalSystemErr, name: '시스템오류' } { value: totalSystemErr, name: '시스템오류' }
] ]
}],
graphic: [{
style: {
text: grandTotal.toLocaleString()
}
}] }]
}); });
$("#totalDonutCenterText").text(grandTotal.toLocaleString());
// 호출량 차트 업데이트 // 호출량 차트 업데이트
@@ -455,6 +444,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -501,6 +493,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -587,10 +582,18 @@
</button> </button>
</div> </div>
<div class="title" id="title">API 일별 통계</div> <div class="title" id="title">API 일별 통계</div>
<table class="search_condition" cellspacing="0"> <table class="search_condition" cellspacing="0" style="table-layout:fixed;">
<colgroup>
<col style="width:150px;">
<col>
<col style="width:150px;">
<col>
<col style="width:150px;">
<col>
</colgroup>
<tbody> <tbody>
<tr> <tr>
<th style="width:100px;">조회기간</th> <th>조회기간</th>
<td colspan="5"> <td colspan="5">
<input type="text" name="searchStartDateTime" value="${param.searchStartDateTime}" style="width:100px;"> <input type="text" name="searchStartDateTime" value="${param.searchStartDateTime}" style="width:100px;">
~ ~
@@ -599,29 +602,29 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<th style="width:100px;">API명</th> <th>API명</th>
<td> <td>
<input type="text" name="searchApiName" value="${param.searchApiName}"> <input type="text" name="searchApiName" value="${param.searchApiName}">
</td> </td>
<th style="width:100px;">업무구분</th> <th>업무구분</th>
<td> <td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}"> <input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td> </td>
<th style="width:100px;">인스턴스</th> <th>인스턴스</th>
<td> <td>
<input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}"> <input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}">
</td> </td>
</tr> </tr>
<tr> <tr>
<th style="width:100px;">클라이언트ID</th> <th>클라이언트ID</th>
<td> <td>
<input type="text" name="searchClientId" value="${param.searchClientId}"> <input type="text" name="searchClientId" value="${param.searchClientId}">
</td> </td>
<th style="width:100px;">Inbound Adapter</th> <th>Inbound Adapter</th>
<td> <td>
<input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}"> <input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}">
</td> </td>
<th style="width:100px;">Outbound Adapter</th> <th>Outbound Adapter</th>
<td> <td>
<input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}"> <input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}">
</td> </td>
@@ -630,7 +633,10 @@
</table> </table>
<!-- 도넛 차트 (상단 50%씩) --> <!-- 도넛 차트 (상단 50%씩) -->
<div class="chart-container"> <div class="chart-container">
<div id="totalDonutChart" class="chart"></div> <div class="chart" style="position:relative; padding:0;">
<div id="totalDonutChart" style="width:100%; height:100%;"></div>
<div id="totalDonutCenterText" style="position:absolute; left:44.5%; top:55%; transform:translate(-50%,-50%); font-size:24px; font-weight:bold; color:#333; pointer-events:none; z-index:10;">0</div>
</div>
<div id="callChart" class="chart"></div> <div id="callChart" class="chart"></div>
</div> </div>
@@ -67,22 +67,27 @@
}, },
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: '{b}: {c} ({d}%)' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString() + ' (' + params.percent + '%)';
}
}, },
legend: { legend: {
orient: 'horizontal', orient: 'vertical',
bottom: 10, right: 30,
top: 'middle',
data: ['성공', 'Timeout', '시스템오류'] data: ['성공', 'Timeout', '시스템오류']
}, },
series: [{ series: [{
name: '총건수', name: '총건수',
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['38%', '65%'],
center: ['50%', '50%'], center: ['44%', '55%'],
avoidLabelOverlap: true, avoidLabelOverlap: true,
label: { label: {
show: true, show: true,
formatter: '{b}: {c}' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString();
}
}, },
emphasis: { emphasis: {
label: { label: {
@@ -99,18 +104,6 @@
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } }, { value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } } { value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } }
] ]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}] }]
}; };
@@ -120,7 +113,7 @@
// 호출량 차트 // 호출량 차트
var callOption = { var callOption = {
title: { text: '호출량 추이', left: 'center', textStyle: { fontSize: 14 } }, title: { text: '호출량 추이', left: 'center', top: 10, textStyle: { fontSize: 14 } },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
@@ -227,13 +220,9 @@
{ value: totalTimeout, name: 'Timeout' }, { value: totalTimeout, name: 'Timeout' },
{ value: totalSystemErr, name: '시스템오류' } { value: totalSystemErr, name: '시스템오류' }
] ]
}],
graphic: [{
style: {
text: grandTotal.toLocaleString()
}
}] }]
}); });
$("#totalDonutCenterText").text(grandTotal.toLocaleString());
// 호출량 차트 업데이트 // 호출량 차트 업데이트
@@ -457,6 +446,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -503,6 +495,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -567,41 +562,47 @@
</button> </button>
</div> </div>
<div class="title" id="title">API 시간별 통계</div> <div class="title" id="title">API 시간별 통계</div>
<table class="search_condition" cellspacing="0"> <table class="search_condition" cellspacing="0" style="table-layout:fixed;">
<colgroup>
<col style="width:150px;">
<col>
<col style="width:150px;">
<col>
<col style="width:150px;">
<col>
</colgroup>
<tbody> <tbody>
<tr> <tr>
<th style="width:100px;">조회일자</th> <th>조회일자</th>
<td colspan="3"> <td colspan="5">
<input type="text" name="searchDate" value="${param.searchDate}" style="width:100px;"> <input type="text" name="searchDate" value="${param.searchDate}" style="width:100px;">
<span style="color:#888; font-size:12px; margin-left:10px;">(선택일 00시 ~ 23시 조회)</span> <span style="color:#888; font-size:12px; margin-left:10px;">(선택일 00시 ~ 23시 조회)</span>
</td> </td>
</tr> </tr>
<tr> <tr>
<th style="width:100px;">API명</th> <th>API명</th>
<td> <td>
<input type="text" name="searchApiName" value="${param.searchApiName}"> <input type="text" name="searchApiName" value="${param.searchApiName}">
</td> </td>
<th style="width:100px;">인스턴스</th> <th>업무구분</th>
<td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td>
<th>인스턴스</th>
<td> <td>
<input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}"> <input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}">
</td> </td>
</tr> </tr>
<tr> <tr>
<th style="width:100px;">업무구분</th> <th>클라이언트ID</th>
<td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td>
<th style="width:100px;">클라이언트ID</th>
<td> <td>
<input type="text" name="searchClientId" value="${param.searchClientId}"> <input type="text" name="searchClientId" value="${param.searchClientId}">
</td> </td>
</tr> <th>Inbound Adapter</th>
<tr>
<th style="width:100px;">Inbound Adapter</th>
<td> <td>
<input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}"> <input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}">
</td> </td>
<th style="width:100px;">Outbound Adapter</th> <th>Outbound Adapter</th>
<td> <td>
<input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}"> <input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}">
</td> </td>
@@ -610,7 +611,10 @@
</table> </table>
<!-- 도넛 차트 (상단 50%씩) --> <!-- 도넛 차트 (상단 50%씩) -->
<div class="chart-container"> <div class="chart-container">
<div id="totalDonutChart" class="chart"></div> <div class="chart" style="position:relative; padding:0;">
<div id="totalDonutChart" style="width:100%; height:100%;"></div>
<div id="totalDonutCenterText" style="position:absolute; left:44.5%; top:55%; transform:translate(-50%,-50%); font-size:24px; font-weight:bold; color:#333; pointer-events:none; z-index:10;">0</div>
</div>
<div id="callChart" class="chart"></div> <div id="callChart" class="chart"></div>
</div> </div>
@@ -59,22 +59,27 @@
}, },
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: '{b}: {c} ({d}%)' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString() + ' (' + params.percent + '%)';
}
}, },
legend: { legend: {
orient: 'horizontal', orient: 'vertical',
bottom: 10, right: 30,
top: 'middle',
data: ['성공', 'Timeout', '시스템오류'] data: ['성공', 'Timeout', '시스템오류']
}, },
series: [{ series: [{
name: '총건수', name: '총건수',
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['38%', '65%'],
center: ['50%', '50%'], center: ['44%', '55%'],
avoidLabelOverlap: true, avoidLabelOverlap: true,
label: { label: {
show: true, show: true,
formatter: '{b}: {c}' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString();
}
}, },
emphasis: { emphasis: {
label: { label: {
@@ -91,18 +96,6 @@
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } }, { value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } } { value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } }
] ]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}] }]
}; };
@@ -112,7 +105,7 @@
// 호출량 차트 // 호출량 차트
var callOption = { var callOption = {
title: { text: '호출량 추이', left: 'center', textStyle: { fontSize: 14 } }, title: { text: '호출량 추이', left: 'center', top: 10, textStyle: { fontSize: 14 } },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
@@ -189,13 +182,9 @@
{ value: totalTimeout, name: 'Timeout' }, { value: totalTimeout, name: 'Timeout' },
{ value: totalSystemErr, name: '시스템오류' } { value: totalSystemErr, name: '시스템오류' }
] ]
}],
graphic: [{
style: {
text: grandTotal.toLocaleString()
}
}] }]
}); });
$("#totalDonutCenterText").text(grandTotal.toLocaleString());
// 호출량 차트 업데이트 // 호출량 차트 업데이트
@@ -470,6 +459,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -516,6 +508,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -525,35 +520,26 @@
$("input[name=searchStartDate], input[name=searchEndDate]").inputmask("9999-99-99", { 'autoUnmask': true }); $("input[name=searchStartDate], input[name=searchEndDate]").inputmask("9999-99-99", { 'autoUnmask': true });
$("input[name=searchStartTime], input[name=searchEndTime]").inputmask("99:99", { 'autoUnmask': true }); $("input[name=searchStartTime], input[name=searchEndTime]").inputmask("99:99", { 'autoUnmask': true });
// 기본값 설정 (현재 시간 기준 1시간 전 정각 ~ 현재 정각) // 기본값 설정 (현재 시간(분) 기준 1시간 전 ~ 현재 시간(분))
var now = new Date(); var now = new Date();
var endHour = now.getHours(); var oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
var startHour = endHour - 1;
var startDate, endDate;
if (startHour < 0) { function formatDate(d) {
startHour = 23; return d.getFullYear() + '-' +
var yesterday = new Date(now); String(d.getMonth() + 1).padStart(2, '0') + '-' +
yesterday.setDate(yesterday.getDate() - 1); String(d.getDate()).padStart(2, '0');
startDate = yesterday.getFullYear() + '-' + }
String(yesterday.getMonth() + 1).padStart(2, '0') + '-' + function formatTime(d) {
String(yesterday.getDate()).padStart(2, '0'); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
endDate = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0');
} else {
var today = getToday();
startDate = today;
endDate = today;
} }
if (!$("input[name=searchStartDate]").val()) { if (!$("input[name=searchStartDate]").val()) {
$("input[name=searchStartDate]").val(startDate); $("input[name=searchStartDate]").val(formatDate(oneHourAgo));
$("input[name=searchStartTime]").val(String(startHour).padStart(2, '0') + ':00'); $("input[name=searchStartTime]").val(formatTime(oneHourAgo));
} }
if (!$("input[name=searchEndDate]").val()) { if (!$("input[name=searchEndDate]").val()) {
$("input[name=searchEndDate]").val(endDate); $("input[name=searchEndDate]").val(formatDate(now));
$("input[name=searchEndTime]").val(String(endHour).padStart(2, '0') + ':00'); $("input[name=searchEndTime]").val(formatTime(now));
} }
initCharts(); initCharts();
@@ -628,7 +614,10 @@
</table> </table>
<!-- 도넛 차트 (상단 50%씩) --> <!-- 도넛 차트 (상단 50%씩) -->
<div class="chart-container"> <div class="chart-container">
<div id="totalDonutChart" class="chart"></div> <div class="chart" style="position:relative; padding:0;">
<div id="totalDonutChart" style="width:100%; height:100%;"></div>
<div id="totalDonutCenterText" style="position:absolute; left:44.5%; top:55%; transform:translate(-50%,-50%); font-size:24px; font-weight:bold; color:#333; pointer-events:none; z-index:10;">0</div>
</div>
<div id="callChart" class="chart"></div> <div id="callChart" class="chart"></div>
</div> </div>
@@ -645,7 +634,17 @@
<div id="pagerSummary"></div> <div id="pagerSummary"></div>
</div> </div>
<!-- 상세 그리드 -->
<div style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<div class="title" style="margin: 0;">상세 통계</div>
<button type="button" class="cssbtn" id="btn_excel_export" level="R">
<i class="material-icons">file_download</i> Excel 다운로드 (상세)
</button>
</div>
<table id="grid"></table>
<div id="pager"></div>
</div>
</div> </div>
</div> </div>
</body> </body>
@@ -66,22 +66,27 @@
}, },
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: '{b}: {c} ({d}%)' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString() + ' (' + params.percent + '%)';
}
}, },
legend: { legend: {
orient: 'horizontal', orient: 'vertical',
bottom: 10, right: 30,
top: 'middle',
data: ['성공', 'Timeout', '시스템오류'] data: ['성공', 'Timeout', '시스템오류']
}, },
series: [{ series: [{
name: '총건수', name: '총건수',
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['38%', '65%'],
center: ['50%', '50%'], center: ['44%', '55%'],
avoidLabelOverlap: true, avoidLabelOverlap: true,
label: { label: {
show: true, show: true,
formatter: '{b}: {c}' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString();
}
}, },
emphasis: { emphasis: {
label: { label: {
@@ -98,18 +103,6 @@
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } }, { value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } } { value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } }
] ]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}] }]
}; };
@@ -119,7 +112,7 @@
// 호출량 차트 // 호출량 차트
var callOption = { var callOption = {
title: { text: '호출량 추이', left: 'center', textStyle: { fontSize: 14 } }, title: { text: '호출량 추이', left: 'center', top: 10, textStyle: { fontSize: 14 } },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
@@ -189,13 +182,9 @@
{ value: totalTimeout, name: 'Timeout' }, { value: totalTimeout, name: 'Timeout' },
{ value: totalSystemErr, name: '시스템오류' } { value: totalSystemErr, name: '시스템오류' }
] ]
}],
graphic: [{
style: {
text: grandTotal.toLocaleString()
}
}] }]
}); });
$("#totalDonutCenterText").text(grandTotal.toLocaleString());
@@ -460,6 +449,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -506,6 +498,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -578,11 +573,19 @@
</button> </button>
</div> </div>
<div class="title" id="title">API 월별 통계</div> <div class="title" id="title">API 월별 통계</div>
<table class="search_condition" cellspacing="0"> <table class="search_condition" cellspacing="0" style="table-layout:fixed;">
<colgroup>
<col style="width:150px;">
<col>
<col style="width:150px;">
<col>
<col style="width:150px;">
<col>
</colgroup>
<tbody> <tbody>
<tr> <tr>
<th style="width:100px;">조회기간</th> <th>조회기간</th>
<td colspan="3"> <td colspan="5">
<input type="text" name="searchStartDateTime" value="${param.searchStartDateTime}" style="width:100px;" placeholder="YYYY-MM"> <input type="text" name="searchStartDateTime" value="${param.searchStartDateTime}" style="width:100px;" placeholder="YYYY-MM">
~ ~
<input type="text" name="searchEndDateTime" value="${param.searchEndDateTime}" style="width:100px;" placeholder="YYYY-MM"> <input type="text" name="searchEndDateTime" value="${param.searchEndDateTime}" style="width:100px;" placeholder="YYYY-MM">
@@ -590,31 +593,29 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<th style="width:100px;">API명</th> <th>API명</th>
<td> <td>
<input type="text" name="searchApiName" value="${param.searchApiName}"> <input type="text" name="searchApiName" value="${param.searchApiName}">
</td> </td>
<th style="width:100px;">인스턴스</th> <th>업무구분</th>
<td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td>
<th>인스턴스</th>
<td> <td>
<input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}"> <input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}">
</td> </td>
</tr> </tr>
<tr> <tr>
<th style="width:100px;">업무구분</th> <th>클라이언트ID</th>
<td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td>
<th style="width:100px;">클라이언트ID</th>
<td> <td>
<input type="text" name="searchClientId" value="${param.searchClientId}"> <input type="text" name="searchClientId" value="${param.searchClientId}">
</td> </td>
</tr> <th>Inbound Adapter</th>
<tr>
<th style="width:100px;">Inbound Adapter</th>
<td> <td>
<input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}"> <input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}">
</td> </td>
<th style="width:100px;">Outbound Adapter</th> <th>Outbound Adapter</th>
<td> <td>
<input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}"> <input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}">
</td> </td>
@@ -623,7 +624,10 @@
</table> </table>
<!-- 도넛 차트 (상단 50%씩) --> <!-- 도넛 차트 (상단 50%씩) -->
<div class="chart-container"> <div class="chart-container">
<div id="totalDonutChart" class="chart"></div> <div class="chart" style="position:relative; padding:0;">
<div id="totalDonutChart" style="width:100%; height:100%;"></div>
<div id="totalDonutCenterText" style="position:absolute; left:44.5%; top:55%; transform:translate(-50%,-50%); font-size:24px; font-weight:bold; color:#333; pointer-events:none; z-index:10;">0</div>
</div>
<div id="callChart" class="chart"></div> <div id="callChart" class="chart"></div>
</div> </div>
@@ -66,22 +66,27 @@
}, },
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: '{b}: {c} ({d}%)' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString() + ' (' + params.percent + '%)';
}
}, },
legend: { legend: {
orient: 'horizontal', orient: 'vertical',
bottom: 10, right: 30,
top: 'middle',
data: ['성공', 'Timeout', '시스템오류'] data: ['성공', 'Timeout', '시스템오류']
}, },
series: [{ series: [{
name: '총건수', name: '총건수',
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['38%', '65%'],
center: ['50%', '50%'], center: ['44%', '55%'],
avoidLabelOverlap: true, avoidLabelOverlap: true,
label: { label: {
show: true, show: true,
formatter: '{b}: {c}' formatter: function (params) {
return params.name + ': ' + params.value.toLocaleString();
}
}, },
emphasis: { emphasis: {
label: { label: {
@@ -98,18 +103,6 @@
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } }, { value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } } { value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } }
] ]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}] }]
}; };
@@ -119,7 +112,7 @@
// 호출량 차트 // 호출량 차트
var callOption = { var callOption = {
title: { text: '호출량 추이', left: 'center', textStyle: { fontSize: 14 } }, title: { text: '호출량 추이', left: 'center', top: 10, textStyle: { fontSize: 14 } },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
@@ -191,13 +184,9 @@
{ value: totalTimeout, name: 'Timeout' }, { value: totalTimeout, name: 'Timeout' },
{ value: totalSystemErr, name: '시스템오류' } { value: totalSystemErr, name: '시스템오류' }
] ]
}],
graphic: [{
style: {
text: grandTotal.toLocaleString()
}
}] }]
}); });
$("#totalDonutCenterText").text(grandTotal.toLocaleString());
@@ -436,6 +425,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -482,6 +474,9 @@
for (var i = 0; i < colModel.length; i++) { for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false }); $(this).setColProp(colModel[i].name, { sortable: false });
} }
// 조회(reloadGrid) 시 컬럼 폭이 고정폭(shrinkToFit:false)으로 남아
// 실제 컨테이너 폭과 어긋나 가로 스크롤이 생기는 것을 방지
$(this).jqGrid('setGridWidth', $('#content_middle').width(), true);
} }
}); });
} }
@@ -549,42 +544,48 @@
</button> </button>
</div> </div>
<div class="title" id="title">API 연간 통계</div> <div class="title" id="title">API 연간 통계</div>
<table class="search_condition" cellspacing="0"> <table class="search_condition" cellspacing="0" style="table-layout:fixed;">
<colgroup>
<col style="width:150px;">
<col>
<col style="width:150px;">
<col>
<col style="width:150px;">
<col>
</colgroup>
<tbody> <tbody>
<tr> <tr>
<th style="width:100px;">조회기간</th> <th>조회기간</th>
<td colspan="3"> <td colspan="5">
<input type="text" name="searchStartDateTime" value="${param.searchStartDateTime}" style="width:100px;" placeholder="YYYY"> <input type="text" name="searchStartDateTime" value="${param.searchStartDateTime}" style="width:100px;" placeholder="YYYY">
~ ~
<input type="text" name="searchEndDateTime" value="${param.searchEndDateTime}" style="width:100px;" placeholder="YYYY"> <input type="text" name="searchEndDateTime" value="${param.searchEndDateTime}" style="width:100px;" placeholder="YYYY">
</td> </td>
</tr> </tr>
<tr> <tr>
<th style="width:100px;">API명</th> <th>API명</th>
<td> <td>
<input type="text" name="searchApiName" value="${param.searchApiName}"> <input type="text" name="searchApiName" value="${param.searchApiName}">
</td> </td>
<th style="width:100px;">인스턴스</th> <th>업무구분</th>
<td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td>
<th>인스턴스</th>
<td> <td>
<input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}"> <input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}">
</td> </td>
</tr> </tr>
<tr> <tr>
<th style="width:100px;">업무구분</th> <th>클라이언트ID</th>
<td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td>
<th style="width:100px;">클라이언트ID</th>
<td> <td>
<input type="text" name="searchClientId" value="${param.searchClientId}"> <input type="text" name="searchClientId" value="${param.searchClientId}">
</td> </td>
</tr> <th>Inbound Adapter</th>
<tr>
<th style="width:100px;">Inbound Adapter</th>
<td> <td>
<input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}"> <input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}">
</td> </td>
<th style="width:100px;">Outbound Adapter</th> <th>Outbound Adapter</th>
<td> <td>
<input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}"> <input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}">
</td> </td>
@@ -593,7 +594,10 @@
</table> </table>
<!-- 도넛 차트 (상단 50%씩) --> <!-- 도넛 차트 (상단 50%씩) -->
<div class="chart-container"> <div class="chart-container">
<div id="totalDonutChart" class="chart"></div> <div class="chart" style="position:relative; padding:0;">
<div id="totalDonutChart" style="width:100%; height:100%;"></div>
<div id="totalDonutCenterText" style="position:absolute; left:44.5%; top:55%; transform:translate(-50%,-50%); font-size:24px; font-weight:bold; color:#333; pointer-events:none; z-index:10;">0</div>
</div>
<div id="callChart" class="chart"></div> <div id="callChart" class="chart"></div>
</div> </div>
@@ -242,6 +242,10 @@
search(); search();
}); });
$("input[name=searchType]").click(function() {
search();
});
$("#btn_excel").click(function() { $("#btn_excel").click(function() {
exportToExcel(); exportToExcel();
}); });
@@ -76,6 +76,15 @@
return cellvalue.substring(1, 4); return cellvalue.substring(1, 4);
} }
function apiStatusFormatter(cellvalue, options, rowObject) {
switch (cellvalue) {
case 'E': return '장애';
case 'D': return '지연';
case 'C': return '점검';
default: return '정상';
}
}
function list() { function list() {
detail() detail()
var gridPostData = getSearchForJqgrid("cmd", "LIST"); //jqgrid에서는 object 로 var gridPostData = getSearchForJqgrid("cmd", "LIST"); //jqgrid에서는 object 로
@@ -90,6 +99,7 @@
'API FULL PATH', 'API FULL PATH',
'요청', '요청',
'응답', '응답',
'상태',
'작성자', '작성자',
'가상응답여부', '가상응답여부',
'변경일시(*)', '변경일시(*)',
@@ -99,11 +109,12 @@
{name: 'eaiSvcName', align: 'left', width: '100', sortable: true}, {name: 'eaiSvcName', align: 'left', width: '100', sortable: true},
{name: 'eaiSvcDesc', align: 'left', sortable: false}, {name: 'eaiSvcDesc', align: 'left', sortable: false},
{name: 'apiFullPath', align: 'left', sortable: false}, {name: 'apiFullPath', align: 'left', sortable: false},
{name: 'fromAdapter', align: 'center', width: '40', formatter: adapterNameShortFormatter, sortable: false}, {name: 'fromAdapter', align: 'center', width: '30', formatter: adapterNameShortFormatter, sortable: false},
{name: 'toAdapter', align: 'center', width: '40', formatter: adapterNameShortFormatter, sortable: false}, {name: 'toAdapter', align: 'center', width: '30', formatter: adapterNameShortFormatter, sortable: false},
{name: 'author', align: 'center', width: '60', sortable: false}, {name: 'statusCode', align: 'center', width: '30', formatter: apiStatusFormatter, sortable: false},
{name: 'author', align: 'center', width: '40', sortable: false},
{name : 'simYn', align : 'center' , width:'40', hidden: true }, {name : 'simYn', align : 'center' , width:'40', hidden: true },
{name : 'lastModifiedDate', align : 'center' , width:'40', sortable: true}, {name : 'lastModifiedDate', align : 'center' , width:'60', sortable: true},
{name: 'syncAsyncType', align: 'center', width: '40', hidden: true}, {name: 'syncAsyncType', align: 'center', width: '40', hidden: true},
], ],
jsonReader: { jsonReader: {
@@ -78,6 +78,22 @@
vertical-align: middle; vertical-align: middle;
} }
/* iframe 모달(showModal) 콘텐츠 패딩 제거 — iframe 이 다이얼로그 폭을 최대한 사용해 내부 가로스크롤 방지 */
.ui-dialog .ui-dialog-content { padding: 0 !important; }
/* OpenAPI Spec 버튼 상태 색상 (PTL_API_SPEC_INFO 기준) */
#btn_openapi_spec.spec-exists { background:#ffe08a !important; border-color:#f0c040 !important; color:#5a4600 !important; }
#btn_openapi_spec.spec-public { background:#b8e6a0 !important; border-color:#7cc257 !important; color:#2c4a15 !important; }
/* 호버 상태 안내 레이어 */
#specStatusLayer {
position: fixed; z-index: 100000; display: none;
background: rgba(30,30,30,0.92); color: #fff;
padding: 8px 12px; border-radius: 6px;
font-size: 12px; line-height: 1.6; max-width: 340px;
white-space: pre-line; pointer-events: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
</style> </style>
<script language="javascript" > <script language="javascript" >
var isDetail = false; var isDetail = false;
@@ -85,6 +101,7 @@
const url_excel ='<c:url value="/onl/transaction/apim/apiInterfaceMan.excel" />'; const url_excel ='<c:url value="/onl/transaction/apim/apiInterfaceMan.excel" />';
var url_spec = '<c:url value="/onl/transaction/apim/apiSpecMan.view"/>'; var url_spec = '<c:url value="/onl/transaction/apim/apiSpecMan.view"/>';
var url_openapi_spec = '<c:url value="/onl/transaction/apim/djbApiSpecMan.view"/>'; var url_openapi_spec = '<c:url value="/onl/transaction/apim/djbApiSpecMan.view"/>';
var url_openapi_spec_json = '<c:url value="/onl/transaction/apim/djbApiSpecMan.json"/>';
var returnUrl ; var returnUrl ;
let headerValues = []; let headerValues = [];
let headerNames = []; let headerNames = [];
@@ -487,6 +504,7 @@
} }
if(data.errResponseTransform != null && data.errResponseTransform != '' && data.errTransformYn == 'Y') { if(data.errResponseTransform != null && data.errResponseTransform != '' && data.errTransformYn == 'Y') {
$('#toggleTransformYn').bootstrapToggle('on');
$('#toggleErrTransformYn').bootstrapToggle('on'); $('#toggleErrTransformYn').bootstrapToggle('on');
$('#rowErrResponseTransform').show(); $('#rowErrResponseTransform').show();
} else { } else {
@@ -536,6 +554,8 @@
$('#inboundHttpMethod').trigger('change'); $('#inboundHttpMethod').trigger('change');
$('#outboundHttpMethod').trigger('change'); $('#outboundHttpMethod').trigger('change');
applyOpenApiSpecStatus(); // OpenAPI Spec 버튼 색상(PTL_API_SPEC_INFO)
}, },
error:function(e){ error:function(e){
alert(e.responseText); alert(e.responseText);
@@ -1011,10 +1031,46 @@
window.open(urlAddServiceType(baseUrl + '&menuId=' + encodeURIComponent(getMenuId())), '_blank'); window.open(urlAddServiceType(baseUrl + '&menuId=' + encodeURIComponent(getMenuId())), '_blank');
} else { } else {
// 기본: 모달 + iframe. showModal 이 serviceType/menuId/pop 을 자동 부착. // 기본: 모달 + iframe. showModal 이 serviceType/menuId/pop 을 자동 부착.
showModal(baseUrl, { title: 'OpenAPI Spec' }, 1600, 880); // 콘텐츠 고정폭 1600 + 다이얼로그 chrome(패딩/보더 ~40px) 때문에 iframe 내부폭이 1600 미만이면 가로스크롤 발생.
// 뷰포트 내에서 최대한 크게 열어(캡 1720) iframe 내부폭이 1600 이상 확보되게 한다.
var vw = $(window).width(), vh = $(window).height();
var modalW = Math.min(vw - 12, 1720);
var modalH = Math.min(vh - 20, 900);
// 스펙 편집 모달 닫힘 시 버튼 색상 재조회(공개여부/등록여부 변경 반영)
showModal(baseUrl, { title: 'OpenAPI Spec' }, modalW, modalH, function(){ applyOpenApiSpecStatus(); });
} }
} }
// OpenAPI Spec 버튼 색상 = PTL_API_SPEC_INFO(api_id = getFullSvcName()) 상태.
// - 미등록: 기본색 - 등록·비공개(display_yn≠Y): 노랑 - 등록·공개(display_yn=Y): 하늘
function applyOpenApiSpecStatus() {
var $btn = $('#btn_openapi_spec');
$btn.removeClass('spec-exists spec-public').data('specMsg', '');
var eaiSvcName = getFullSvcName();
if (!eaiSvcName || eaiSvcName.length < 2) return;
$.ajax({
type: 'POST',
url: url_openapi_spec_json,
dataType: 'json',
data: { cmd: 'SPEC_STATUS', eaiSvcName: eaiSvcName },
success: function(res) {
var exists = res && (res.exists === true || res.exists === 'true');
var isPublic = res && (res.displayYn === 'Y');
var msg;
if (!exists) {
msg = 'OpenAPI Spec 미등록\n버튼을 눌러 스펙을 생성/편집하세요.';
} else if (isPublic) {
$btn.addClass('spec-public');
msg = 'OpenAPI Spec 등록됨 · 포탈 공개중 (display_yn = Y)\n포탈 메인/문서에 노출됩니다.';
} else {
$btn.addClass('spec-exists');
msg = 'OpenAPI Spec 등록됨 · 비공개 (display_yn ≠ Y)\n포탈에는 노출되지 않습니다.';
}
$btn.data('specMsg', msg);
}
});
}
$(document).ready(function() { $(document).ready(function() {
makeValidate(); makeValidate();
// var bootstrapButton = $.fn.button.noConflict() // var bootstrapButton = $.fn.button.noConflict()
@@ -1038,6 +1094,23 @@
showOpenApiSpecPopup(e); showOpenApiSpecPopup(e);
}); });
// OpenAPI Spec 버튼 호버 시 현재 상태 안내 레이어
if ($('#specStatusLayer').length === 0) {
$('body').append('<div id="specStatusLayer"></div>');
}
$("#btn_openapi_spec")
.on('mouseenter', function() {
var msg = $(this).data('specMsg');
if (!msg) return;
$('#specStatusLayer').text(msg).css('display', 'block');
})
.on('mousemove', function(e) {
$('#specStatusLayer').css({ left: (e.clientX + 14) + 'px', top: (e.clientY + 16) + 'px' });
})
.on('mouseleave', function() {
$('#specStatusLayer').hide();
});
$("#btn_modify").click(modifyApi); $("#btn_modify").click(modifyApi);
$("#btn_delete").click(function(){ $("#btn_delete").click(function(){
if(!confirm("삭제하시겠습니까?")){ if(!confirm("삭제하시겠습니까?")){
@@ -1536,9 +1609,9 @@
<button type="button" class="cssbtn" id="btn_delete" level="W" status="DETAIL"><i class="material-icons">delete</i> <%= localeMessage.getString("button.delete") %></button> <button type="button" class="cssbtn" id="btn_delete" level="W" status="DETAIL"><i class="material-icons">delete</i> <%= localeMessage.getString("button.delete") %></button>
<button type="button" class="cssbtn" id="btn_modify" level="R" status="DETAIL,NEW"><i class="material-icons">save</i> <%= localeMessage.getString("button.modify") %></button> <button type="button" class="cssbtn" id="btn_modify" level="R" status="DETAIL,NEW"><i class="material-icons">save</i> <%= localeMessage.getString("button.modify") %></button>
<!-- v1: 기존 API 스펙 (모달) — 기능 비교용 복원 --> <!-- v1: 기존 API 스펙 (모달) — 기능 비교용 복원 -->
<button type="button" class="cssbtn" id="btn_api_spec" level="R" status="DETAIL,NEW"><i class="material-icons">description</i> API 스펙</button> <button type="button" class="cssbtn" id="btn_api_spec" level="R" status="DETAIL"><i class="material-icons">description</i> API 스펙(삭제예정)</button>
<!-- v2: 신규 OpenAPI Spec (새 탭) --> <!-- v2: 신규 OpenAPI Spec (새 탭) -->
<button type="button" class="cssbtn" id="btn_openapi_spec" level="R" status="DETAIL,NEW"><i class="material-icons">api</i> OpenAPI Spec</button> <button type="button" class="cssbtn" id="btn_openapi_spec" level="R" status="DETAIL"><i class="material-icons">api</i> OpenAPI Spec</button>
<button type="button" class="cssbtn" id="btn_previous" level="R" status="DETAIL,NEW"><i class="material-icons">arrow_back</i> <%= localeMessage.getString("button.previous") %></button> <button type="button" class="cssbtn" id="btn_previous" level="R" status="DETAIL,NEW"><i class="material-icons">arrow_back</i> <%= localeMessage.getString("button.previous") %></button>
</div> </div>
<div class="title" id="title" style="font-size:1.4em">${rmsMenuName}</div> <div class="title" id="title" style="font-size:1.4em">${rmsMenuName}</div>
@@ -67,6 +67,9 @@
saveUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.json'/>?cmd=SAVE&serviceType=APIGW", saveUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.json'/>?cmd=SAVE&serviceType=APIGW",
swaggerBase: "<c:url value='/plugins/swaggerUI/'/>", swaggerBase: "<c:url value='/plugins/swaggerUI/'/>",
gwAddress: "${gwAddress}", gwAddress: "${gwAddress}",
useProxy: ${useProxy},
tokenUseProxy: ${tokenUseProxy},
exportEnabled: ${exportEnabled},
orgPopupUrl: "<c:url value='/onl/transaction/apim/apiSpecMan.view'/>", orgPopupUrl: "<c:url value='/onl/transaction/apim/apiSpecMan.view'/>",
groupPopupUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.view'/>?cmd=GROUP_POPUP", groupPopupUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.view'/>?cmd=GROUP_POPUP",
jsonUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.json'/>" jsonUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.json'/>"
@@ -91,6 +94,8 @@
<button id="btn-newwin" class="hidden px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">새창으로 띄우기</button> <button id="btn-newwin" class="hidden px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">새창으로 띄우기</button>
<button id="btn-regen" class="px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">자동 생성(초기화)</button> <button id="btn-regen" class="px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">자동 생성(초기화)</button>
<button id="btn-save" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700">저장</button> <button id="btn-save" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700">저장</button>
<%-- 내보내기 버튼은 PTL_PROPERTY(djb.openapi.export.enabled=true) 일 때만 노출. 기본 미노출. --%>
<c:if test="${exportEnabled}">
<div class="relative"> <div class="relative">
<button id="btn-export" class="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">내보내기 ▾</button> <button id="btn-export" class="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">내보내기 ▾</button>
<div id="export-menu" class="hidden absolute right-0 mt-1 w-44 bg-white border border-slate-200 rounded shadow-lg z-40"> <div id="export-menu" class="hidden absolute right-0 mt-1 w-44 bg-white border border-slate-200 rounded shadow-lg z-40">
@@ -98,6 +103,7 @@
<button data-export="json" class="block w-full text-left px-3 py-2 text-sm hover:bg-slate-50">JSON 다운로드</button> <button data-export="json" class="block w-full text-left px-3 py-2 text-sm hover:bg-slate-50">JSON 다운로드</button>
</div> </div>
</div> </div>
</c:if>
<button id="btn-close" class="px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">닫기</button> <button id="btn-close" class="px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">닫기</button>
</div> </div>
</header> </header>
@@ -380,15 +380,15 @@
{name: 'TRACKASISKEY2CTNT', hidden: true}, {name: 'TRACKASISKEY2CTNT', hidden: true},
{name: 'EAISVCSERNO_TMP', align: 'left', width: '220', formatter: trimFormatter}, {name: 'EAISVCSERNO_TMP', align: 'left', width: '220', formatter: trimFormatter},
{name: 'KEYMGTMSGCTNT', align: 'left', width: '240'}, {name: 'KEYMGTMSGCTNT', align: 'left', width: '300'},
{name: 'LOGPRCSSSERNO_TMP', align: 'center', width: '40'}, {name: 'LOGPRCSSSERNO_TMP', align: 'center', width: '40'},
{name: 'TELGMRECVTRANCD', align: 'left', width: '135', hidden: true}, {name: 'TELGMRECVTRANCD', align: 'left', width: '135', hidden: true},
{name: 'EAISVCNAME', align: 'left', width: '130'}, {name: 'EAISVCNAME', align: 'left', width: '160'},
{name: 'EAISVCDESC', align: 'left', width: '280'}, {name: 'EAISVCDESC', align: 'left', width: '280'},
{name: 'EAIBZWKDSTCD_TMP', align: 'center', width: '50'}, {name: 'EAIBZWKDSTCD_TMP', align: 'center', width: '50'},
{name: 'EXTBIZCD', align: 'center', width: '50', hidden: isApiGw}, {name: 'EXTBIZCD', align: 'center', width: '50', hidden: isApiGw},
{name: 'REFKEY', align: 'left', width: '280', hidden: isApiGw}, {name: 'REFKEY', align: 'left', width: '280', hidden: isApiGw},
{name: 'CLIENTNAME', align: 'left', width: '260', hidden: !isApiGw}, {name: 'CLIENTNAME', align: 'left', width: '120', hidden: !isApiGw},
{name: 'ORGNAME', align: 'left', width: '100', hidden: !isApiGw, formatter: orgNameFormatter}, {name: 'ORGNAME', align: 'left', width: '100', hidden: !isApiGw, formatter: orgNameFormatter},
{name: 'MSGDPSTYMS_TMP', align: 'center', width: '150', formatter: timeStampFormat2}, {name: 'MSGDPSTYMS_TMP', align: 'center', width: '150', formatter: timeStampFormat2},
{name: 'MSGPRCSSYMS', align: 'center', width: '150', formatter: timeStampFormat2}, {name: 'MSGPRCSSYMS', align: 'center', width: '150', formatter: timeStampFormat2},
+18 -9
View File
@@ -4,6 +4,7 @@ plugins {
id 'eclipse-wtp' id 'eclipse-wtp'
id 'idea' id 'idea'
id 'war' id 'war'
id 'org.cyclonedx.bom' version '3.2.4'
} }
group 'com.eactive' group 'com.eactive'
@@ -54,7 +55,7 @@ war {
def profile = project.findProperty("profile") ?: "tomcat" def profile = project.findProperty("profile") ?: "tomcat"
if (profile == "weblogic") { if (profile == "weblogic") {
webXml = file("WebContent/WEB-INF/weblogic-web.xml") webXml = file("WebContent/WEB-INF/weblogic-web.xml")
exclude 'WEB-INF/web.xml' exclude 'WEB-INF/web.xml'
} }
@@ -75,12 +76,8 @@ tasks.withType(JavaCompile) {
dependencies { dependencies {
annotationProcessor "org.projectlombok:lombok:1.18.28", annotationProcessor "org.projectlombok:lombok:1.18.28", "org.projectlombok:lombok-mapstruct-binding:0.2.0", "org.mapstruct:mapstruct-processor:1.5.5.Final"
"org.projectlombok:lombok-mapstruct-binding:0.2.0", annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}:jpa", "jakarta.persistence:jakarta.persistence-api:2.2.3", "jakarta.annotation:jakarta.annotation-api:1.3.5"
"org.mapstruct:mapstruct-processor:1.5.5.Final"
annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}:jpa",
"jakarta.persistence:jakarta.persistence-api:2.2.3",
"jakarta.annotation:jakarta.annotation-api:1.3.5"
implementation project(':elink-online-core') implementation project(':elink-online-core')
implementation project(':elink-online-transformer') implementation project(':elink-online-transformer')
@@ -88,7 +85,7 @@ dependencies {
implementation project(':elink-online-emsclient') implementation project(':elink-online-emsclient')
implementation project(':elink-portal-common') implementation project(':elink-portal-common')
//implementation project(':kjb-safedb') //implementation project(':kjb-safedb')
implementation project(":eapim-admin-djb") //implementation project(":eapim-admin-djb")
/* Custom Libs (damo-manager.jar 는 Tomcat lib 가 런타임 제공 → compileOnly, WAR 미포함) */ /* Custom Libs (damo-manager.jar 는 Tomcat lib 가 런타임 제공 → compileOnly, WAR 미포함) */
implementation fileTree(dir: 'libs', include: ['*.jar'], exclude: ['damo-manager.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'], exclude: ['damo-manager.jar'])
@@ -121,6 +118,12 @@ dependencies {
//implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1' //implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1'
// apistatus-draft.yml (자동 탐지 초안 양식) 로딩용.
// admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없어 직접 파싱한다.
// 2.x 를 쓴다 - 1.x 는 CVE-2022-1471(Constructor 역직렬화 RCE) 및
// CVE-2022-25857/38749~38752(DoS) 영향. 로딩도 SafeConstructor 로 한다.
implementation 'org.yaml:snakeyaml:2.4'
implementation "javax.jms:javax.jms-api:2.0" implementation "javax.jms:javax.jms-api:2.0"
implementation "org.snmp4j:snmp4j:1.10.1" implementation "org.snmp4j:snmp4j:1.10.1"
implementation "com.googlecode.json-simple:json-simple:1.1.1" implementation "com.googlecode.json-simple:json-simple:1.1.1"
@@ -256,7 +259,11 @@ eclipse {
} }
synchronizationTasks settingEclipseEncoding, initDirs synchronizationTasks settingEclipseEncoding, initDirs
jdt { jdt {
// apt {
// // generated 된 패스 경로를 .setting/org.eclipse.jdt.apt.core.prefs 의 org.eclipse.jdt.apt.genSrcDir에 적용한다.
// // project > properties > Java Compiler > Annoation Processing 화면에서 확인 가능하다.
// genSrcDir = file(generatedJavaDir)
// }
} }
project { project {
if (!natures.contains('org.eclipse.buildship.core.gradleprojectnature')) { if (!natures.contains('org.eclipse.buildship.core.gradleprojectnature')) {
@@ -271,3 +278,5 @@ tasks.withType(JavaCompile) {
options.forkOptions.memoryMaximumSize = '4g' // 컴파일러에 2GB 할당 (필요시 4g로 증량) options.forkOptions.memoryMaximumSize = '4g' // 컴파일러에 2GB 할당 (필요시 4g로 증량)
options.encoding = 'UTF-8' options.encoding = 'UTF-8'
} }
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
+326
View File
@@ -0,0 +1,326 @@
/*
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
*
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
*
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
* classpath 나 WAR 산출물에는 포함되지 않는다.
*
* 시트: 요약 / WAR 기준
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
*/
buildscript {
repositories {
maven {
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
allowInsecureProtocol = true
}
mavenCentral()
}
dependencies {
classpath 'org.apache.poi:poi-ooxml:3.17'
}
}
import groovy.json.JsonSlurper
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.xssf.usermodel.XSSFWorkbook
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
ext.SBOM_CELL_LIMIT = 32000
task sbomXlsx {
group = 'sbom'
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
if (!project.hasProperty('sbomJson')) {
dependsOn 'cyclonedxBom'
}
doLast {
File src = resolveBomJson(project)
File out = project.hasProperty('sbomOut')
? project.file(project.property('sbomOut'))
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
out.parentFile.mkdirs()
def bom = new JsonSlurper().parse(src, 'UTF-8')
def deploy = collectDeployJars(project)
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
def wb = new XSSFWorkbook()
def st = createStyles(wb)
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
writeWarSheet(wb, st, warRows)
out.withOutputStream { os -> wb.write(os) }
wb.close()
int unmatched = warRows.count { it.matched == 'N' }
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
}
}
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
String sbomFileName(Project p) {
def warTask = p.tasks.findByName('war')
if (warTask != null) {
String archive = warTask.archiveFileName.get()
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
}
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
return "${p.name}${ver}-sbom.xlsx"
}
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
File resolveBomJson(Project p) {
if (p.hasProperty('sbomJson')) {
File f = p.file(p.property('sbomJson'))
if (!f.exists()) {
throw new GradleException("bom.json 없음: ${f.absolutePath}")
}
return f
}
def candidates = [
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
new File(p.buildDir, 'reports/bom.json'),
]
File found = candidates.find { it.exists() }
if (found == null) {
throw new GradleException(
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
}
return found
}
/**
* 실제 배포물에 packaging 되는 jar 목록.
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
*
* @return [label: 기준 설명, jars: 행 목록]
*/
Map collectDeployJars(Project p) {
def cfg = p.configurations.findByName('runtimeClasspath')
if (cfg == null) {
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
return [label: '(없음)', jars: []]
}
def warTask = p.tasks.findByName('war')
def files
String label
if (warTask != null) {
files = warTask.classpath.files
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
} else {
files = cfg.files
label = 'runtimeClasspath (war 태스크 없음)'
}
def coordByFile = [:]
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
def id = a.moduleVersion.id
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
}
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
def c = coordByFile[f]
[
file : f.name,
group : c?.group ?: '',
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
version: c?.version ?: '',
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
]
}.sort { it.file }
return [label: label, jars: jars]
}
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
Map indexComponents(bom) {
String rootRef = bom.metadata?.component?.'bom-ref'
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
def index = [:]
bom.components?.each { c ->
def hashes = [:]
c.hashes?.each { h -> hashes[h.alg] = h.content }
def licenses = (c.licenses ?: []).collect { l ->
l.license?.id ?: l.license?.name ?: l.expression ?: ''
}.findAll { it }
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
licenses : licenses.join('; '),
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
purl : c.purl ?: '',
sha256 : hashes['SHA-256'] ?: '',
sha1 : hashes['SHA-1'] ?: '',
description: c.description ?: '',
]
}
return index
}
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
List joinWarRows(List warJars, Map index) {
def result = []
warJars.eachWithIndex { j, i ->
def m = j.coord ? index[j.coord] : null
result << [
no : i + 1,
file : j.file,
group : j.group,
name : j.name,
version : j.version,
direct : m?.direct ?: '',
licenses : m?.licenses ?: '',
licenseList: m?.licenseList ?: ['(미상)'],
purl : m?.purl ?: '',
sha256 : m?.sha256 ?: '',
sha1 : m?.sha1 ?: '',
matched : (m != null) ? 'Y' : 'N',
description: m?.description ?: '',
]
}
return result
}
Map createStyles(wb) {
def headFont = wb.createFont()
headFont.setBold(true)
headFont.setColor(IndexedColors.WHITE.getIndex())
def head = wb.createCellStyle()
head.setFont(headFont)
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
head.setAlignment(HorizontalAlignment.CENTER)
head.setVerticalAlignment(VerticalAlignment.CENTER)
head.setBorderBottom(BorderStyle.THIN)
def body = wb.createCellStyle()
body.setVerticalAlignment(VerticalAlignment.TOP)
def wrap = wb.createCellStyle()
wrap.setVerticalAlignment(VerticalAlignment.TOP)
wrap.setWrapText(true)
def labelFont = wb.createFont()
labelFont.setBold(true)
def label = wb.createCellStyle()
label.setFont(labelFont)
return [head: head, body: body, wrap: wrap, label: label]
}
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
def writeHeader(sheet, style, List<String> headers, List<Integer> widths) {
def row = sheet.createRow(0)
row.setHeightInPoints(20f)
headers.eachWithIndex { h, i ->
def cell = row.createCell(i)
cell.setCellValue(h)
cell.setCellStyle(style)
sheet.setColumnWidth(i, widths[i] * 256)
}
sheet.createFreezePane(0, 1)
}
def cellOf(row, int idx, value, style) {
def cell = row.createCell(idx)
String s = (value == null) ? '' : value.toString()
if (s.length() > SBOM_CELL_LIMIT) {
s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
}
cell.setCellValue(s)
cell.setCellStyle(style)
return cell
}
def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
def sheet = wb.createSheet('요약')
def comp = bom.metadata?.component ?: [:]
def tool = bom.metadata?.tools?.components?.getAt(0)
Set licenseKinds = warRows.collectMany { it.licenseList } as Set
def items = [
['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
['serialNumber', bom.serialNumber ?: ''],
['생성 시각', bom.metadata?.timestamp ?: ''],
['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
['원본 파일', src.absolutePath],
['배포 수록 jar', warRows.size()],
[' └ 직접 의존', warRows.count { it.direct == '직접' }],
[' └ 전이 의존', warRows.count { it.direct == '전이' }],
[' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
['라이선스 종류', licenseKinds.size()],
['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
]
writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
items.eachWithIndex { item, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, item[0], st.label)
cellOf(row, 1, item[1], st.body)
}
// 라이선스 분포 (요약 하단)
def byLicense = [:].withDefault { 0 }
warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
int base = items.size() + 2
def hdr = sheet.createRow(base)
cellOf(hdr, 0, '라이선스', st.head)
cellOf(hdr, 1, 'jar 수', st.head)
sorted.eachWithIndex { e, i ->
def row = sheet.createRow(base + 1 + i)
cellOf(row, 0, e.key, st.body)
cellOf(row, 1, e.value, st.body)
}
}
/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
def writeWarSheet(wb, st, List warRows) {
def sheet = wb.createSheet('WAR 기준')
def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
writeHeader(sheet, st.head, headers, widths)
warRows.eachWithIndex { r, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, r.no, st.body)
cellOf(row, 1, r.file, st.body)
cellOf(row, 2, r.group, st.body)
cellOf(row, 3, r.name, st.body)
cellOf(row, 4, r.version, st.body)
cellOf(row, 5, r.direct, st.body)
cellOf(row, 6, r.licenses, st.body)
cellOf(row, 7, r.purl, st.body)
cellOf(row, 8, r.sha256, st.body)
cellOf(row, 9, r.sha1, st.body)
cellOf(row, 10, r.matched, st.body)
cellOf(row, 11, r.description, st.wrap)
}
if (!warRows.isEmpty()) {
sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ include 'elink-online-common'
include 'elink-online-emsclient' include 'elink-online-emsclient'
include 'elink-portal-common' include 'elink-portal-common'
//include 'kjb-safedb' //include 'kjb-safedb'
include 'eapim-admin-djb' //include 'eapim-admin-djb'
project (':elink-online-core-jpa').projectDir = new File(settingsDir, "../eapim-online/elink-online-core-jpa") project (':elink-online-core-jpa').projectDir = new File(settingsDir, "../eapim-online/elink-online-core-jpa")
project (':elink-online-core').projectDir = new File(settingsDir, "../eapim-online/elink-online-core") project (':elink-online-core').projectDir = new File(settingsDir, "../eapim-online/elink-online-core")
@@ -16,4 +16,4 @@ project (':elink-online-common').projectDir = new File(settingsDir, "../eapim-on
project (':elink-online-emsclient').projectDir = new File(settingsDir, "../eapim-online/elink-online-emsclient") project (':elink-online-emsclient').projectDir = new File(settingsDir, "../eapim-online/elink-online-emsclient")
project (':elink-portal-common').projectDir = new File(settingsDir, "../elink-portal-common") project (':elink-portal-common').projectDir = new File(settingsDir, "../elink-portal-common")
//project (':kjb-safedb').projectDir = new File(settingsDir, "../kjb-safedb") //project (':kjb-safedb').projectDir = new File(settingsDir, "../kjb-safedb")
project (':eapim-admin-djb').projectDir = new File(settingsDir, "../eapim-admin-djb") //project (':eapim-admin-djb').projectDir = new File(settingsDir, "../eapim-admin-djb")
@@ -41,10 +41,12 @@ public class AuditLogInterceptor implements HandlerInterceptor {
if (isAuditableUsecase.isAuditable(userId, command, logType, systemCode)) { if (isAuditableUsecase.isAuditable(userId, command, logType, systemCode)) {
String parameters = getParametersAsString(request); String parameters = getParametersAsString(request);
String message = request.getParameter("auditReason");
log log
.debug("audit log save : logType={}, cmd={}, user={}, parameters={}", logType, command, .debug("audit log save : logType={}, cmd={}, user={}, parameters={}", logType, command,
userId, parameters); userId, parameters);
saveAuditLogUseCase.log(systemCode, logType, command, remoteAddress, userId, parameters); saveAuditLogUseCase
.log(systemCode, logType, command, remoteAddress, userId, parameters, message);
} }
} }
@@ -51,6 +51,9 @@ public class AuditLogPageRepositoryImpl implements AuditLogPageRepository {
if (StringUtils.isNotBlank(command.getSearchRemoteAddress())) { if (StringUtils.isNotBlank(command.getSearchRemoteAddress())) {
jpaQuery.where(qAuditLogEntity.remoteAddress.containsIgnoreCase(command.getSearchRemoteAddress())); jpaQuery.where(qAuditLogEntity.remoteAddress.containsIgnoreCase(command.getSearchRemoteAddress()));
} }
if (StringUtils.isNotBlank(command.getSearchParameters())) {
jpaQuery.where(qAuditLogEntity.parameters.containsIgnoreCase(command.getSearchParameters()));
}
long totalCount = jpaQuery.fetchOne(); long totalCount = jpaQuery.fetchOne();
List<AuditLogEntity> auditLogs = jpaQuery List<AuditLogEntity> auditLogs = jpaQuery
@@ -23,6 +23,8 @@ public class AuditLog {
private String logTypeText; private String logTypeText;
private String message;
private String parameters; private String parameters;
private LocalDateTime lastModifiedDate; private LocalDateTime lastModifiedDate;
@@ -3,6 +3,6 @@ package com.eactive.eai.rms.common.acl.audit.port.in;
public interface SaveAuditLogUseCase { public interface SaveAuditLogUseCase {
public void log(String systemCode, String logType, String command, String remoteAddress, String userId, public void log(String systemCode, String logType, String command, String remoteAddress, String userId,
String parameters); String parameters, String message);
} }
@@ -18,6 +18,7 @@ public class SelectListAuditLogCommand {
private String searchUserId; private String searchUserId;
private String searchRemoteAddress; private String searchRemoteAddress;
private String searchParameters;
@JsonFormat(pattern = "yyyyMMddHHmmss") @JsonFormat(pattern = "yyyyMMddHHmmss")
@DateTimeFormat(pattern = "yyyyMMddHHmmss") @DateTimeFormat(pattern = "yyyyMMddHHmmss")
@@ -59,7 +59,7 @@ class AuditLogService
@Override @Override
public void log(String systemCode, String logType, String command, String remoteAddress, String userId, public void log(String systemCode, String logType, String command, String remoteAddress, String userId,
String parameters) { String parameters, String message) {
AuditLog auditLog = new AuditLog(systemCode, logType, command, userId, remoteAddress); AuditLog auditLog = new AuditLog(systemCode, logType, command, userId, remoteAddress);
String auditPointKey = AuditPoint.generateKey(logType, systemCode, command); String auditPointKey = AuditPoint.generateKey(logType, systemCode, command);
@@ -68,6 +68,7 @@ class AuditLogService
auditLog.setLogTypeText(auditPoint.getLogTypeText()); auditLog.setLogTypeText(auditPoint.getLogTypeText());
auditLog.setLastModifiedDate(LocalDateTime.now()); auditLog.setLastModifiedDate(LocalDateTime.now());
auditLog.setParameters(parameters); auditLog.setParameters(parameters);
auditLog.setMessage(message);
saveAuditLogPort.save(auditLog); saveAuditLogPort.save(auditLog);
} }
@@ -0,0 +1,30 @@
package com.eactive.eai.rms.common.acl.sitemap;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.eactive.eai.rms.common.base.BaseAnnotationController;
import com.eactive.eai.rms.common.login.SessionManager;
@Controller
@RequiredArgsConstructor
public class SitemapController extends BaseAnnotationController {
private final SitemapService sitemapService;
@GetMapping(value = "/common/acl/sitemap/sitemapMan.view")
public String view(@RequestParam(value = "serviceType", required = false) String serviceType,
HttpServletRequest request, Model model) {
List<String> roleIds = SessionManager.getRoleId(request);
model.addAttribute("sitemapTree", sitemapService.getSitemapTree(roleIds, serviceType));
return "/common/acl/sitemap/sitemapMan";
}
}
@@ -0,0 +1,11 @@
package com.eactive.eai.rms.common.acl.sitemap;
import java.util.List;
import com.eactive.eai.rms.common.acl.sitemap.ui.SitemapNode;
public interface SitemapService {
public List<SitemapNode> getSitemapTree(List<String> roleIds, String serviceType);
}
@@ -0,0 +1,96 @@
package com.eactive.eai.rms.common.acl.sitemap;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.eai.rms.common.acl.sitemap.ui.SitemapNode;
import com.eactive.eai.rms.common.base.BaseService;
import com.eactive.eai.rms.data.entity.man.menu.Menu;
import com.eactive.eai.rms.data.entity.man.menu.MenuService;
import com.eactive.eai.rms.data.entity.man.role.RoleMenuAuthService;
@Service("sitemapService")
@Transactional(transactionManager = "transactionManagerForEMS")
public class SitemapServiceImpl extends BaseService implements SitemapService {
@Autowired
private MenuService menuService;
@Autowired
private RoleMenuAuthService roleMenuAuthService;
public List<SitemapNode> getSitemapTree(List<String> roleIds, String serviceType) {
Menu root = menuService.getById("ROOT_DEV");
Menu langRoot = findLangRoot(root);
List<SitemapNode> tree = new ArrayList<>();
if (langRoot == null) {
return tree;
}
Set<String> serviceTypeMenuIds = StringUtils.isBlank(serviceType)
? null
: new HashSet<>(roleMenuAuthService.findMenuIdsByRoleIdsAndServiceType(roleIds, serviceType));
for (Menu menu : sortedChildren(langRoot)) {
if (isVisible(menu) && matchesServiceType(menu, serviceTypeMenuIds)) {
tree.add(buildNode(menu, serviceTypeMenuIds));
}
}
return tree;
}
// 엔티티의 childMenus는 menuId 기준으로 정렬되어 있어, 실제 메뉴(운영화면 GNB)와 동일하게 sortOrder 기준으로 재정렬한다.
private List<Menu> sortedChildren(Menu menu) {
List<Menu> children = new ArrayList<>(menu.getChildMenus());
children.sort(Comparator.comparing(Menu::getSortOrder, Comparator.nullsLast(Comparator.naturalOrder())));
return children;
}
// MenuService.getMenuList()의 GNB 렌더링과 동일하게 "ROOT"(한글)/"ROOT_EN"(영문) 이름의 언어별 루트를 찾는다.
private Menu findLangRoot(Menu root) {
String lang = LocaleContextHolder.getLocale().getLanguage();
String langRootName = (StringUtils.isBlank(lang) || "ko".equalsIgnoreCase(lang))
? "ROOT"
: "ROOT_" + lang.toUpperCase();
return root.getChildMenus().stream()
.filter(m -> langRootName.equalsIgnoreCase(m.getMenuName()))
.findFirst()
.orElse(null);
}
private SitemapNode buildNode(Menu menu, Set<String> serviceTypeMenuIds) {
SitemapNode node = new SitemapNode(menu);
for (Menu child : sortedChildren(menu)) {
if (isVisible(child) && matchesServiceType(child, serviceTypeMenuIds)) {
node.getChildren().add(buildNode(child, serviceTypeMenuIds));
}
}
return node;
}
private boolean isVisible(Menu menu) {
return "Y".equalsIgnoreCase(menu.getDisplayYn()) && "Y".equalsIgnoreCase(menu.getUseYn());
}
// serviceType 필터가 없으면 통과. 있으면 자신이나 하위메뉴 중 하나라도 해당 serviceType 권한이 있어야 통과(상위메뉴 누락 방지).
private boolean matchesServiceType(Menu menu, Set<String> serviceTypeMenuIds) {
if (serviceTypeMenuIds == null) {
return true;
}
if (serviceTypeMenuIds.contains(menu.getMenuId())) {
return true;
}
return menu.getChildMenus().stream().anyMatch(child -> matchesServiceType(child, serviceTypeMenuIds));
}
}
@@ -0,0 +1,29 @@
package com.eactive.eai.rms.common.acl.sitemap.ui;
import java.util.ArrayList;
import java.util.List;
import com.eactive.eai.rms.data.entity.man.menu.Menu;
import lombok.Data;
@Data
public class SitemapNode {
private String menuId;
private String menuName;
private String menuUrl;
private List<SitemapNode> children = new ArrayList<>();
public SitemapNode(Menu menu) {
this.menuId = menu.getMenuId();
this.menuName = menu.getMenuName();
this.menuUrl = menu.getMenuUrl();
}
public SitemapNode() {
}
}
@@ -42,6 +42,8 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserManService extends BaseService { public class UserManService extends BaseService {
private static final String USER_STATUS_NORMAL = "1";
private final UserInfoService userInfoService; private final UserInfoService userInfoService;
private final UserRoleService userRoleService; private final UserRoleService userRoleService;
private final UserBusinessService userBusinessService; private final UserBusinessService userBusinessService;
@@ -98,9 +100,12 @@ public class UserManService extends BaseService {
UserInfo userInfo = userUIMapper.toEntity(userUI); UserInfo userInfo = userUIMapper.toEntity(userUI);
userInfo.setRoleidnfiname(CommonConstants.DEPT_DEVELOPER); userInfo.setRoleidnfiname(CommonConstants.DEPT_DEVELOPER);
userInfo.setStatus(USER_STATUS_NORMAL);
String subfix = monitoringContext.getStringProperty(MonitoringContext.RMS_PASSWORD_INIT_SUBFIX, "@!");
try { try {
userInfo.setPassword(DamoManager.getInstance().hash(DamoManager.SHA256,userUI.getUserId())); userInfo.setPassword(DamoManager.getInstance().hash(DamoManager.SHA256,userUI.getUserId()+subfix));
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Error encrypting password", e); throw new RuntimeException("Error encrypting password", e);
} }
@@ -129,6 +134,12 @@ public class UserManService extends BaseService {
public void update(UserUI userUI) { public void update(UserUI userUI) {
UserInfo userInfo = userInfoService.getById(userUI.getUserId()); UserInfo userInfo = userInfoService.getById(userUI.getUserId());
userUIMapper.updateToEntity(userUI, userInfo); userUIMapper.updateToEntity(userUI, userInfo);
// 계정상태를 정상으로 저장하면 로그인 실패횟수 초기화
if (USER_STATUS_NORMAL.equals(userInfo.getStatus())) {
userInfo.setLoginfailcount(0);
}
userInfoService.save(userInfo); userInfoService.save(userInfo);
} }
@@ -140,9 +151,15 @@ public class UserManService extends BaseService {
} }
public void updatePassword(UserUI userUI) throws Exception { public void updatePassword(UserUI userUI) throws Exception {
String subfix = monitoringContext.getStringProperty(MonitoringContext.RMS_PASSWORD_INIT_SUBFIX, "@!");
UserInfo userInfo = userInfoService.getById(userUI.getUserId()); UserInfo userInfo = userInfoService.getById(userUI.getUserId());
userInfo.setPassword(DamoManager.getInstance().hash(DamoManager.SHA256, userUI.getUserId())); userInfo.setPassword(DamoManager.getInstance().hash(DamoManager.SHA256, userUI.getUserId()+subfix));
userUIMapper.updateToEntity(userUI, userInfo); userUIMapper.updateToEntity(userUI, userInfo);
// 비밀번호 초기화 시 계정상태를 정상으로 변경하고 로그인 실패횟수 초기화
userInfo.setLoginfailcount(0);
userInfo.setStatus(USER_STATUS_NORMAL);
userInfoService.save(userInfo); userInfoService.save(userInfo);
} }
@@ -24,6 +24,8 @@ public interface UserUIMapper extends GenericMapper<UserUI, UserInfo> {
@Mapping(source = "secondmentstdt", target = "SECONDMENTSTDT") @Mapping(source = "secondmentstdt", target = "SECONDMENTSTDT")
@Mapping(source = "secondmentendt", target = "SECONDMENTENDT") @Mapping(source = "secondmentendt", target = "SECONDMENTENDT")
@Mapping(source = "allowip", target = "allowIp") @Mapping(source = "allowip", target = "allowIp")
@Mapping(source = "lastloginyms", target = "LASTLOGINYMS")
@Mapping(source = "regdyms", target = "REGDYMS")
UserUI toVo(UserInfo entity); UserUI toVo(UserInfo entity);
@Override @Override
@@ -82,4 +82,14 @@ public class UserUI {
@JsonProperty("STATUS") @JsonProperty("STATUS")
private String status; // 사용자계정상태 private String status; // 사용자계정상태
@JsonProperty("LASTLOGINYMS")
@JsonFormat(pattern = LocalDateTimeFormatters.FORMAT_YYYYMMDDHHMMSS_14)
@DateTimeFormat(pattern = "yyyyMMddHHmmss")
private LocalDateTime LASTLOGINYMS; // 최종로그인일시
@JsonProperty("REGDYMS")
@JsonFormat(pattern = LocalDateTimeFormatters.FORMAT_YYYYMMDDHHMMSS_14)
@DateTimeFormat(pattern = "yyyyMMddHHmmss")
private LocalDateTime REGDYMS; // 등록일시
} }
@@ -255,12 +255,21 @@ public interface MonitoringContext {
// 이중 로그인 허용여부 // 이중 로그인 허용여부
public static final String RMS_DUAL_LOGIN_ENABLED = "rms.DUAL_LOGIN_ENABLED"; public static final String RMS_DUAL_LOGIN_ENABLED = "rms.DUAL_LOGIN_ENABLED";
// 자동 로그아웃 타임아웃 (단위: 분, 기본값: 10) // 자동 로그아웃 타임아웃 (단위: 분, 기본값: 60)
public static final String RMS_AUTO_LOGOUT_TIMEOUT = "rms.auto.logout.timeout"; public static final String RMS_AUTO_LOGOUT_TIMEOUT = "rms.auto.logout.timeout";
// 웹훅 재전송 설정 // 웹훅
public static final String API_WEBHOOK_RETRY_COUNT = "api.webhook.retry_count"; public static final String RMS_WEBHOOK_REVERSE_PROXY_URL = "rms.webhook.reverse_proxy.url";
public static final String API_WEBHOOK_RETRY_TIME = "api.webhook.retry_time"; public static final String RMS_WEBHOOK_RETRY_COUNT = "rms.webhook.retry_count";
public static final String RMS_WEBHOOK_RETRY_TIME = "rms.webhook.retry_time";
//비밀번호 초기화 접미사
public static final String RMS_PASSWORD_INIT_SUBFIX = "rms.password.init.subfix";
// 최대 로그인 실패 허용횟수 (0이면 계정 잠금 미적용)
public static final String RMS_PASSWORD_FAIL_COUNT = "rms.password.fail.count";
@@ -21,8 +21,8 @@ import com.eactive.eai.rms.common.util.ContainerUtil;
import com.eactive.eai.rms.data.entity.man.monitoringProperty.MonitoringPropertyId; import com.eactive.eai.rms.data.entity.man.monitoringProperty.MonitoringPropertyId;
import com.eactive.eai.rms.data.entity.man.monitoringProperty.service.MonitoringPropertyGroupService; import com.eactive.eai.rms.data.entity.man.monitoringProperty.service.MonitoringPropertyGroupService;
import com.eactive.eai.rms.data.entity.man.monitoringProperty.service.MonitoringPropertyService; import com.eactive.eai.rms.data.entity.man.monitoringProperty.service.MonitoringPropertyService;
import com.eactive.ext.kjb.common.KjbPropertyHolder; import com.eactive.eai.rms.ext.djb.common.DjbPropertyHolder;
import com.eactive.ext.kjb.util.KjbPropertyInjector; import com.eactive.eai.rms.ext.djb.common.DjbPropertyInjector;
@Service("monitoringContext") @Service("monitoringContext")
@Transactional(transactionManager = "transactionManagerForEMS") @Transactional(transactionManager = "transactionManagerForEMS")
@@ -36,7 +36,7 @@ class MonitoringContextImpl implements MonitoringContext {
@Autowired @Autowired
private MonitoringContextDAO monitoringContextDAO; private MonitoringContextDAO monitoringContextDAO;
private Properties properties = null; private Properties properties = new Properties();
private String instancePropertyGroupName; private String instancePropertyGroupName;
@@ -45,7 +45,7 @@ class MonitoringContextImpl implements MonitoringContext {
public void refresh() { public void refresh() {
init(); init();
KjbPropertyHolder.reload(); DjbPropertyHolder.reload();
} }
public void refresh(String propName) { public void refresh(String propName) {
@@ -73,7 +73,10 @@ class MonitoringContextImpl implements MonitoringContext {
String hostIp = InetAddress.getLocalHost().getHostAddress(); String hostIp = InetAddress.getLocalHost().getHostAddress();
this.instancePropertyGroupName = String this.instancePropertyGroupName = String
.format(MonitoringPropertyGroupService.MONITORING_PROPERTY_KEY_FORMAT, hostIp); .format(MonitoringPropertyGroupService.MONITORING_PROPERTY_KEY_FORMAT, hostIp);
this.properties = monitoringContextDAO.getProperties(instancePropertyGroupName); Properties loaded = monitoringContextDAO.getProperties(instancePropertyGroupName);
if (loaded != null) {
this.properties = loaded;
}
String eaiAgentId = System.getProperty(EAI_AGENTID); String eaiAgentId = System.getProperty(EAI_AGENTID);
if (StringUtils.isBlank(eaiAgentId)) { if (StringUtils.isBlank(eaiAgentId)) {
@@ -85,9 +88,9 @@ class MonitoringContextImpl implements MonitoringContext {
setPortMapper(monitoringContextDAO.getPortMap()); setPortMapper(monitoringContextDAO.getPortMap());
// KjbPropertyHolder 초기화 // DjbPropertyHolder 초기화
KjbPropertyInjector injector = new KjbPropertyInjector(monitoringPropertyService, "Monitoring"); DjbPropertyInjector injector = new DjbPropertyInjector(monitoringPropertyService, "Monitoring");
KjbPropertyHolder.initialize(injector::inject); DjbPropertyHolder.initialize(injector::inject);
logger.info("SSO AgentId : " + System.getProperty(EAI_AGENTID)); logger.info("SSO AgentId : " + System.getProperty(EAI_AGENTID));
@@ -45,4 +45,9 @@ public class LoginResponseDto {
* 에러 메시지 (로그인 실패 시) * 에러 메시지 (로그인 실패 시)
*/ */
private String errorMessage; private String errorMessage;
/**
* 초기 비밀번호 변경 필요 여부
*/
private boolean changePassword;
} }
@@ -32,7 +32,6 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.View; import org.springframework.web.servlet.View;
import com.eactive.eai.common.seed.Seed; import com.eactive.eai.common.seed.Seed;
import com.eactive.eai.rms.ext.kjb.smsauth.SmsAuthService;
import com.eactive.ext.djb.DamoManager; import com.eactive.ext.djb.DamoManager;
import com.eactive.eai.data.converter.LocalDateTimeFormatters; import com.eactive.eai.data.converter.LocalDateTimeFormatters;
import com.eactive.eai.rms.common.context.MonitoringContext; import com.eactive.eai.rms.common.context.MonitoringContext;
@@ -51,6 +50,7 @@ import com.eactive.eai.rms.data.entity.man.user.UserLoginHistoryService;
import com.eactive.eai.rms.data.entity.man.user.UserRole; import com.eactive.eai.rms.data.entity.man.user.UserRole;
import com.eactive.eai.rms.data.entity.man.user.UserRoleService; import com.eactive.eai.rms.data.entity.man.user.UserRoleService;
import com.eactive.eai.rms.data.entity.man.user.UserServiceTypeService; import com.eactive.eai.rms.data.entity.man.user.UserServiceTypeService;
import com.eactive.eai.rms.ext.djb.smsauth.SmsAuthService;
@Controller @Controller
public class MainController implements InterceptorSkipController { public class MainController implements InterceptorSkipController {
@@ -64,6 +64,9 @@ public class MainController implements InterceptorSkipController {
private static final String REDIRECT_LOGIN_URL = "redirect:/emergency.jsp"; private static final String REDIRECT_LOGIN_URL = "redirect:/emergency.jsp";
private static final String REDIRECT_CHANGE_PASSWORD_URL = "redirect:/emergency.jsp"; private static final String REDIRECT_CHANGE_PASSWORD_URL = "redirect:/emergency.jsp";
private static final String USER_STATUS_LOCKED = "2";
private static final int DEFAULT_MAX_LOGIN_FAIL_COUNT = 5;
private final LocaleMessage localeMessage; private final LocaleMessage localeMessage;
private final MonitoringContext monitoringContext; private final MonitoringContext monitoringContext;
@@ -160,7 +163,7 @@ public class MainController implements InterceptorSkipController {
session.removeAttribute(RESULT_MSG); session.removeAttribute(RESULT_MSG);
return LoginResponseDto.builder() return LoginResponseDto.builder()
.success(false) .success(false)
.errorMessage(errorMsg != null ? errorMsg : "로그인에 실패했습니다.") .errorMessage(toAlertMessage(errorMsg != null ? errorMsg : "로그인에 실패했습니다."))
.build(); .build();
} }
@@ -192,6 +195,19 @@ public class MainController implements InterceptorSkipController {
.build(); .build();
} }
// 2.1 초기 비밀번호인 경우, 비밀번호 변경해야 함
String subfix = monitoringContext.getStringProperty(MonitoringContext.RMS_PASSWORD_INIT_SUBFIX, "@!");
if (userInfo.getPassword().equals(DamoManager.getInstance().hash(DamoManager.SHA256, userInfo.getUserid()+subfix))
|| userInfo.getPassword().equals(DamoManager.getInstance().hash(DamoManager.SHA256, userInfo.getUserid()))) {
return LoginResponseDto.builder()
.success(false)
.changePassword(true)
.errorMessage("비밀번호 변경후 사용가능합니다.")
.build();
}
// 3. SMS 인증 필요 여부 체크 // 3. SMS 인증 필요 여부 체크
SmsAuthService smsAuthService = getSmsAuthService(); SmsAuthService smsAuthService = getSmsAuthService();
if (smsAuthService != null && smsAuthService.isEnabled()) { if (smsAuthService != null && smsAuthService.isEnabled()) {
@@ -332,9 +348,19 @@ public class MainController implements InterceptorSkipController {
//logger.info("사용자 계정 상태: " + userInfo.getStatus()); //logger.info("사용자 계정 상태: " + userInfo.getStatus());
if ( userInfo.getStatus() != null && "2".equals(userInfo.getStatus()) ) { if ( USER_STATUS_LOCKED.equals(userInfo.getStatus()) ) {
int maxLoginFailCount = getMaxLoginFailCount();
boolean lockedByFailCount = maxLoginFailCount > 0
&& userInfo.getLoginfailcount() != null
&& userInfo.getLoginfailcount() >= maxLoginFailCount;
if (lockedByFailCount) {
setLoginFailure(request, session, "login.accountlocked1",
"login.accountlocked2", String.valueOf(maxLoginFailCount));
} else {
setLoginFailure(request, session, "login.checkstatus1", setLoginFailure(request, session, "login.checkstatus1",
"login.checkstatus2"); "login.checkstatus2");
}
return null; return null;
} }
@@ -361,8 +387,9 @@ public class MainController implements InterceptorSkipController {
/* 기존로직 - LDAP 사용 x */ /* 기존로직 - LDAP 사용 x */
if (!userInfo.getPassword().equals(DamoManager.getInstance().hash(DamoManager.SHA256, dto.getPassword()))) { if (!userInfo.getPassword().equals(DamoManager.getInstance().hash(DamoManager.SHA256, dto.getPassword()))) {
// password 다름. // password 다름.
int failCount = increaseLoginFailCount(userInfo);
setLoginFailure(request, session, "login.loginfail1", setLoginFailure(request, session, "login.loginfail1",
"login.loginfail2"); "login.loginfail2", failCount);
return null; return null;
} }
} else { } else {
@@ -376,8 +403,9 @@ public class MainController implements InterceptorSkipController {
//if (!authenticate(userInfo.getUserid(), dto.getPassword())) { //if (!authenticate(userInfo.getUserid(), dto.getPassword())) {
if (!authenticate(userInfo.getUserid(), password)) { if (!authenticate(userInfo.getUserid(), password)) {
// password 다름. // password 다름.
int failCount = increaseLoginFailCount(userInfo);
setLoginFailure(request, session, "login.loginfail1", setLoginFailure(request, session, "login.loginfail1",
"login.loginfail2"); "login.loginfail2", failCount);
return null; return null;
} }
} }
@@ -419,6 +447,27 @@ public class MainController implements InterceptorSkipController {
return userInfo; return userInfo;
} }
// 최대 로그인 실패 허용횟수 조회 (rms.password.fail.count, 0이면 계정 잠금 미적용)
private int getMaxLoginFailCount() {
return monitoringContext.getIntProperty(
MonitoringContext.RMS_PASSWORD_FAIL_COUNT, DEFAULT_MAX_LOGIN_FAIL_COUNT);
}
// 로그인 실패 횟수 증가, 임계치 도달 시 계정 잠금
private int increaseLoginFailCount(UserInfo userInfo) {
int failCount = (userInfo.getLoginfailcount() == null ? 0 : userInfo.getLoginfailcount()) + 1;
userInfo.setLoginfailcount(failCount);
int maxLoginFailCount = getMaxLoginFailCount();
if (maxLoginFailCount > 0 && failCount >= maxLoginFailCount) {
userInfo.setStatus(USER_STATUS_LOCKED);
logger.warn("로그인 " + maxLoginFailCount + "회 이상 실패로 계정 잠금 처리: " + userInfo.getUserid());
}
userInfoService.save(userInfo);
return failCount;
}
// LDAP // LDAP
public boolean authenticate(String userId, String password) { public boolean authenticate(String userId, String password) {
String LDAP_URL = monitoringPropertyService.getPrpty2ValById("Monitoring", "ldap.domain"); String LDAP_URL = monitoringPropertyService.getPrpty2ValById("Monitoring", "ldap.domain");
@@ -483,6 +532,7 @@ public class MainController implements InterceptorSkipController {
String lastLoginIp = IpUtil.getClientIp(request); String lastLoginIp = IpUtil.getClientIp(request);
userInfo.setLastloginyms(LocalDateTime.now()); userInfo.setLastloginyms(LocalDateTime.now());
userInfo.setLastloginip(lastLoginIp); userInfo.setLastloginip(lastLoginIp);
userInfo.setLoginfailcount(0);
userInfoService.save(userInfo); userInfoService.save(userInfo);
} }
@@ -543,12 +593,32 @@ public class MainController implements InterceptorSkipController {
return "redirect:/common/errors/errorLogon.jsp"; return "redirect:/common/errors/errorLogon.jsp";
} }
// HTML 개행 태그(<BR/> 등)를 JS alert용 개행문자로 변환
private String toAlertMessage(String message) {
return message.replaceAll("(?i)<br\\s*/?>", "\n");
}
// 로그인 실패 시 로그 기록, 세션에 실패 메시지 설정 // 로그인 실패 시 로그 기록, 세션에 실패 메시지 설정
private void setLoginFailure(HttpServletRequest request, private void setLoginFailure(HttpServletRequest request,
HttpSession session, String logKey, String msgKey) { HttpSession session, String logKey, String msgKey,
String... msgArgs) {
UserAccessLogger.log(request, LOG_CATEGORY_LOGIN, "F", UserAccessLogger.log(request, LOG_CATEGORY_LOGIN, "F",
localeMessage.getString(logKey)); localeMessage.getString(logKey));
session.setAttribute(RESULT_MSG, localeMessage.getString(msgKey)); session.setAttribute(RESULT_MSG, localeMessage.getString(msgKey, msgArgs));
}
// 로그인 실패 시 로그 기록, 세션에 실패 메시지(실패 횟수 포함) 설정
private void setLoginFailure(HttpServletRequest request,
HttpSession session, String logKey, String msgKey,
int failCount) {
UserAccessLogger.log(request, LOG_CATEGORY_LOGIN, "F",
localeMessage.getString(logKey));
String msg = localeMessage.getString(msgKey);
int maxLoginFailCount = getMaxLoginFailCount();
if (maxLoginFailCount > 0) {
msg += " (" + failCount + "/" + maxLoginFailCount + ")";
}
session.setAttribute(RESULT_MSG, msg);
} }
// 서비스 타입 검증 // 서비스 타입 검증
@@ -614,7 +684,7 @@ public class MainController implements InterceptorSkipController {
model.addAttribute("mainPage", mainPage); model.addAttribute("mainPage", mainPage);
model.addAttribute("menuId", menuId); model.addAttribute("menuId", menuId);
model.addAttribute("autoLogoutTimeout", model.addAttribute("autoLogoutTimeout",
monitoringContext.getIntProperty(MonitoringContext.RMS_AUTO_LOGOUT_TIMEOUT, 10)); monitoringContext.getIntProperty(MonitoringContext.RMS_AUTO_LOGOUT_TIMEOUT, 60));
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debug("CURRENT SERVICE TYPE : " + service); logger.debug("CURRENT SERVICE TYPE : " + service);
@@ -758,6 +828,9 @@ public class MainController implements InterceptorSkipController {
String changePassword, String confirmPassword, String changePassword, String confirmPassword,
String resetUserId, String resetPassword) { String resetUserId, String resetPassword) {
HttpSession session = request.getSession(); HttpSession session = request.getSession();
// AJAX 로그인 인증 후 초기비밀번호 변경 경로에서 세션에 부분 로그인 상태가 남아
// root emergency.jsp가 main.do로 잘못 리다이렉트하는 것을 방지
session.removeAttribute(CommonConstants.LOGIN);
try { try {
@@ -815,6 +888,8 @@ public class MainController implements InterceptorSkipController {
.getString("login.pwdchanged")); .getString("login.pwdchanged"));
session.setAttribute(RESULT_MSG, localeMessage session.setAttribute(RESULT_MSG, localeMessage
.getString("login.pwdchanged")); .getString("login.pwdchanged"));
logger.debug("\n\n\n\n===================" + localeMessage
.getString("login.pwdchanged"));
return REDIRECT_LOGIN_URL; return REDIRECT_LOGIN_URL;
} catch (Exception e) { } catch (Exception e) {
@@ -106,7 +106,7 @@ public class MonitoringPropertyManController extends BaseAnnotationController {
monitoringContext.refresh(); monitoringContext.refresh();
String url = "/monitoring/refresh.do"; String url = "/monitoring/refresh.do";
Map<String, String> ret = monitoringSyncAgent.targetSync(url, paramMap); Map<String, String> ret = monitoringSyncAgent.sync(url, paramMap);
return ret.get("result"); return ret.get("result");
} }
@@ -51,4 +51,19 @@ public class RoleMenuAuthService extends AbstractEMSDataSerivce<RoleMenuAuth, Ro
.fetch(); .fetch();
} }
public List<String> findMenuIdsByRoleIdsAndServiceType(List<String> roleIds, String serviceType) {
QRoleMenuAuth qRoleMenuAuth = QRoleMenuAuth.roleMenuAuth;
return getJPAQueryFactory()
.select(qRoleMenuAuth.id.menuId)
.distinct()
.from(qRoleMenuAuth)
.where(qRoleMenuAuth.id.roleId.in(roleIds),
qRoleMenuAuth.auth.in("R", "W"),
qRoleMenuAuth.id.serviceType.eq(serviceType))
.setHint(QueryHints.CACHEABLE, true)
.fetch();
}
} }
@@ -14,6 +14,8 @@ import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
@Service @Service
@Transactional(transactionManager = "transactionManagerForEMS") @Transactional(transactionManager = "transactionManagerForEMS")
public class MessageRequestService public class MessageRequestService
@@ -53,6 +55,27 @@ public class MessageRequestService
return repository.findAll(predicate, fixed); return repository.findAll(predicate, fixed);
} }
/** 특정 일자에 SMS/KAKAO 로 발송 요청된 건수 (UMS 일련번호 채번용). */
public long countSmsKakaoRequestsOnDate(LocalDate date) {
QMessageRequest q = QMessageRequest.messageRequest;
BooleanBuilder predicate = new BooleanBuilder();
predicate.and(q.messageType.in("SMS", "KAKAO"));
predicate.and(q.requestDate.goe(date.atStartOfDay()));
predicate.and(q.requestDate.loe(date.atTime(23, 59, 59)));
return repository.count(predicate);
}
/** 특정 일자에 EMAIL 로 발송 요청된 건수 (UMS 일련번호 채번용). */
public long countEmailRequestsOnDate(LocalDate date) {
QMessageRequest q = QMessageRequest.messageRequest;
BooleanBuilder predicate = new BooleanBuilder();
predicate.and(q.messageType.in("EMAIL"));
predicate.and(q.requestDate.goe(date.atStartOfDay()));
predicate.and(q.requestDate.loe(date.atTime(23, 59, 59)));
return repository.count(predicate);
}
/** PENDING 상태인 건만 FAILED 로 전환한다. */ /** PENDING 상태인 건만 FAILED 로 전환한다. */
public void updateStatusToFailedIfPending(String id) { public void updateStatusToFailedIfPending(String id) {
MessageRequest entity = getById(id); MessageRequest entity = getById(id);
@@ -0,0 +1,41 @@
package com.eactive.eai.rms.data.entity.onl.djb.apistatus;
import java.math.BigDecimal;
/**
* 판정 구간 내 오류가 1건 이상 집계된 API 의 오류율 (개발 모드 진단 로그용)
*
* <p>{@code ApiStatusRepository.findApiErrorRates} 결과. 장애 판정 임계값과 무관하게
* 오류율 0% 초과인 API 를 모두 담는다.</p>
*/
public interface ApiErrorRateRow {
/** API(EAI 서비스)명 */
String getEaisvcname();
/** 현재 상태 코드 - 정상(N), 점검(C), 지연(D), 장애(E) */
String getStatusCode();
/** 판정 구간 내 총 건수 (성공 + 타임아웃 + 시스템오류 + 업무오류) */
BigDecimal getTotal();
/** 판정 구간 내 성공 건수 */
BigDecimal getSuccess();
/** 오류율(%) - (총건수 - 성공건수) / 총건수 * 100 */
BigDecimal getErrorRate();
/** 오류 판정 구간 내 건수 가중 평균 응답시간(ms) */
BigDecimal getAvgRespTime();
/**
* 지연 판정 구간 내 총 건수.
*
* <p>지연은 오류와 판정 구간이 다르다({@code api.status.delay.range_minute}).
* 지연 임계 비교는 이 구간의 평균 응답시간으로 해야 실제 판정과 일치한다.</p>
*/
BigDecimal getDelayTotal();
/** 지연 판정 구간 내 건수 가중 평균 응답시간(ms) */
BigDecimal getDelayAvgRespTime();
}
@@ -0,0 +1,9 @@
package com.eactive.eai.rms.data.entity.onl.djb.apistatus;
/**
* EAI 서비스명 조회 결과 (TSEAIHE01)
*/
public interface ApiNameRow {
String getEaisvcname();
String getEaisvcdesc();
}
@@ -26,9 +26,12 @@ public class WebhookSendLog {
) )
private Long id; private Long id;
@Column(name = "TARGET_URL", nullable = false, length = 500) @Column(name = "TARGET_URL", length = 500)
private String targetUrl; private String targetUrl;
@Column(name = "PROXY_URL", length = 500)
private String proxyUrl;
@Column(name = "EVENT_TYPE", length = 100) @Column(name = "EVENT_TYPE", length = 100)
private String eventType; private String eventType;
@@ -66,6 +69,9 @@ public class WebhookSendLog {
@Column(name = "SENT_AT") @Column(name = "SENT_AT")
private LocalDateTime sentAt; private LocalDateTime sentAt;
@Column(name = "SEND_BY", length = 100)
private String sendBy;
/* Boolean 편의 메서드 */ /* Boolean 편의 메서드 */
public void setSuccess(Boolean success) { public void setSuccess(Boolean success) {
this.success = (success != null && success) ? "Y" : "N"; this.success = (success != null && success) ? "Y" : "N";
@@ -22,6 +22,7 @@ import com.eactive.eai.data.entity.onl.message.QServiceMessageEntity;
import com.eactive.eai.data.entity.onl.routing.QRouting; import com.eactive.eai.data.entity.onl.routing.QRouting;
import com.eactive.eai.data.entity.onl.stdmessage.QStandardMessageInfo; import com.eactive.eai.data.entity.onl.stdmessage.QStandardMessageInfo;
import com.eactive.eai.data.jpa.AbstractDataService; import com.eactive.eai.data.jpa.AbstractDataService;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.QApiStatus;
import com.eactive.eai.rms.data.entity.onl.authserver.QScopeInfo; import com.eactive.eai.rms.data.entity.onl.authserver.QScopeInfo;
import com.eactive.eai.rms.data.entity.onl.bzwkdstcd.UserBusinessService; import com.eactive.eai.rms.data.entity.onl.bzwkdstcd.UserBusinessService;
import com.querydsl.core.BooleanBuilder; import com.querydsl.core.BooleanBuilder;
@@ -45,6 +46,7 @@ public class EAIMessageService extends AbstractDataService<EAIMessageEntity, Str
QServiceMessageEntity qServiceMessageEntity = QServiceMessageEntity.serviceMessageEntity; QServiceMessageEntity qServiceMessageEntity = QServiceMessageEntity.serviceMessageEntity;
QRouting qRouting = QRouting.routing; QRouting qRouting = QRouting.routing;
QStandardMessageInfo qStdInfo = QStandardMessageInfo.standardMessageInfo; QStandardMessageInfo qStdInfo = QStandardMessageInfo.standardMessageInfo;
QApiStatus qApiStatus = QApiStatus.apiStatus;
JPAQuery<Tuple> jpaQuery = createBaseQuery(eaiMessageUISearch); JPAQuery<Tuple> jpaQuery = createBaseQuery(eaiMessageUISearch);
@@ -60,6 +62,7 @@ public class EAIMessageService extends AbstractDataService<EAIMessageEntity, Str
, qRouting.nonmotivrouturiname , qRouting.nonmotivrouturiname
, qStdInfo.apifullpath , qStdInfo.apifullpath
, qStdInfo.bzwksvckeyname , qStdInfo.bzwksvckeyname
, qApiStatus.statusCode
) )
.limit(pageable.getPageSize()) .limit(pageable.getPageSize())
@@ -105,6 +108,7 @@ public class EAIMessageService extends AbstractDataService<EAIMessageEntity, Str
QServiceMessageEntity qServiceMessageEntity = QServiceMessageEntity.serviceMessageEntity; QServiceMessageEntity qServiceMessageEntity = QServiceMessageEntity.serviceMessageEntity;
QRouting qRouting = QRouting.routing; QRouting qRouting = QRouting.routing;
QStandardMessageInfo qStdInfo = QStandardMessageInfo.standardMessageInfo; QStandardMessageInfo qStdInfo = QStandardMessageInfo.standardMessageInfo;
QApiStatus qApiStatus = QApiStatus.apiStatus;
BooleanBuilder predicate = createSelectListPredicate(eaiMessageUISearch); BooleanBuilder predicate = createSelectListPredicate(eaiMessageUISearch);
@@ -114,6 +118,7 @@ public class EAIMessageService extends AbstractDataService<EAIMessageEntity, Str
, qRouting.nonmotivrouturiname , qRouting.nonmotivrouturiname
, qStdInfo.apifullpath , qStdInfo.apifullpath
, qStdInfo.bzwksvckeyname , qStdInfo.bzwksvckeyname
, qApiStatus.statusCode
) )
.from(qeaiMessageEntity) .from(qeaiMessageEntity)
.join(qRouting) .join(qRouting)
@@ -122,6 +127,8 @@ public class EAIMessageService extends AbstractDataService<EAIMessageEntity, Str
.on(qServiceMessageEntity.id.svcprcssno.eq(1)) .on(qServiceMessageEntity.id.svcprcssno.eq(1))
.leftJoin(qStdInfo) .leftJoin(qStdInfo)
.on(qeaiMessageEntity.eaisvcname.eq(qStdInfo.eaisvcname)) .on(qeaiMessageEntity.eaisvcname.eq(qStdInfo.eaisvcname))
.leftJoin(qApiStatus)
.on(qeaiMessageEntity.eaisvcname.eq(qApiStatus.eaisvcname))
.where(predicate); .where(predicate);
} }
@@ -1,5 +1,6 @@
package com.eactive.eai.rms.data.entity.onl.inflow; package com.eactive.eai.rms.data.entity.onl.inflow;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -63,11 +64,19 @@ public class InflowControlLogService
public Iterable<InflowControlLog> findAll(InflowControlHistoryManUISearch uiSearch) { public Iterable<InflowControlLog> findAll(InflowControlHistoryManUISearch uiSearch) {
QInflowControlLog qInflowConfrolLog = QInflowControlLog.inflowControlLog; QInflowControlLog qInflowConfrolLog = QInflowControlLog.inflowControlLog;
String startTime = StringUtils.defaultIfEmpty(uiSearch.getSearchStartTime(), "000000");
String endTime = StringUtils.defaultIfEmpty(uiSearch.getSearchEndTime(), "235959");
BooleanBuilder booleanBuilder = new BooleanBuilder(); BooleanBuilder booleanBuilder = new BooleanBuilder();
booleanBuilder booleanBuilder
.and(qInflowConfrolLog.id.msgdpstyms .and(qInflowConfrolLog.id.msgdpstyms
.between(uiSearch.getSearchStartDate() + "000000000", .between(uiSearch.getSearchStartDate() + startTime + "000",
uiSearch.getSearchEndDate() + "235959999")); uiSearch.getSearchEndDate() + endTime + "999"));
if (!StringUtils.isEmpty(uiSearch.getSearchInstanceName()))
booleanBuilder.and(qInflowConfrolLog.eaisevrinstncname.contains(uiSearch.getSearchInstanceName()));
if (!StringUtils.isEmpty(uiSearch.getSearchApiName()))
booleanBuilder.and(qInflowConfrolLog.eaisvcname.contains(uiSearch.getSearchApiName()));
if (!StringUtils.isEmpty(uiSearch.getSearchAdapterGroupName()))
booleanBuilder.and(qInflowConfrolLog.adptrbzwkgroupname.contains(uiSearch.getSearchAdapterGroupName()));
return super.repository.findAll(booleanBuilder); return super.repository.findAll(booleanBuilder);
} }
} }
@@ -27,6 +27,8 @@ import com.eactive.eai.rms.onl.manage.inflow.history.InflowControlHistoryManUISe
import com.eactive.eai.rms.onl.manage.inflow.inflow.InflowControlManMapper; import com.eactive.eai.rms.onl.manage.inflow.inflow.InflowControlManMapper;
import com.querydsl.core.BooleanBuilder; import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple; import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.StringPath;
import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory; import com.querydsl.jpa.impl.JPAQueryFactory;
@@ -46,7 +48,12 @@ public class InflowControlService extends AbstractDataService<InflowControl, Inf
@Autowired @Autowired
InflowControlManMapper mapper; InflowControlManMapper mapper;
public Page<InflowControlServiceDto> findAllForAdapter(Pageable pageable, String searchName) { // 유량제어 미설정(useyn null)은 "사용안함" 검색 시 함께 조회되도록 처리
private static BooleanExpression useYnCondition(StringPath useYnPath, String searchUseYn) {
return "0".equals(searchUseYn) ? useYnPath.eq(searchUseYn).or(useYnPath.isNull()) : useYnPath.eq(searchUseYn);
}
public Page<InflowControlServiceDto> findAllForAdapter(Pageable pageable, String searchName, String searchUseYn) {
JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager); JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
QAdapterGroup qAdapterGroup = QAdapterGroup.adapterGroup; QAdapterGroup qAdapterGroup = QAdapterGroup.adapterGroup;
QInflowControl qInflowControl = QInflowControl.inflowControl; QInflowControl qInflowControl = QInflowControl.inflowControl;
@@ -54,6 +61,8 @@ public class InflowControlService extends AbstractDataService<InflowControl, Inf
BooleanBuilder boolBuilder = new BooleanBuilder(); BooleanBuilder boolBuilder = new BooleanBuilder();
if (!StringUtils.isEmpty(searchName)) if (!StringUtils.isEmpty(searchName))
boolBuilder.and(qAdapterGroup.adptrbzwkgroupname.contains(searchName)); boolBuilder.and(qAdapterGroup.adptrbzwkgroupname.contains(searchName));
if (!StringUtils.isEmpty(searchUseYn))
boolBuilder.and(useYnCondition(qInflowControl.useyn, searchUseYn));
BooleanBuilder adapterWherePredicate = new BooleanBuilder(qAdapterGroup.adptriodstcd BooleanBuilder adapterWherePredicate = new BooleanBuilder(qAdapterGroup.adptriodstcd
.eq("I") .eq("I")
@@ -84,6 +93,10 @@ public class InflowControlService extends AbstractDataService<InflowControl, Inf
long totalCount = jpaQueryFactory long totalCount = jpaQueryFactory
.select(qAdapterGroup.adptrbzwkgroupname.count()) .select(qAdapterGroup.adptrbzwkgroupname.count())
.from(qAdapterGroup) .from(qAdapterGroup)
.leftJoin(qInflowControl)
.on(qAdapterGroup.adptrbzwkgroupname
.eq(qInflowControl.id.name)
.and(qInflowControl.id.type.eq(ADAPTER_TYPE_INFLOW)))
.where(boolBuilder) .where(boolBuilder)
.fetchOne(); .fetchOne();
@@ -121,17 +134,22 @@ public class InflowControlService extends AbstractDataService<InflowControl, Inf
return toDto(qAdapterGroup, qInflowControl, tuple); return toDto(qAdapterGroup, qInflowControl, tuple);
} }
public Page<InflowControlServiceDto> findAllForInterface(Pageable pageable, String searchName) { public Page<InflowControlServiceDto> findAllForInterface(Pageable pageable, String searchName, String searchUseYn) {
JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager); JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
QInflowControl qInflowControl = QInflowControl.inflowControl; QInflowControl qInflowControl = QInflowControl.inflowControl;
QEAIMessageEntity qEAIMessageEntity = QEAIMessageEntity.eAIMessageEntity; QEAIMessageEntity qEAIMessageEntity = QEAIMessageEntity.eAIMessageEntity;
BooleanBuilder boolBuilder = new BooleanBuilder();
boolBuilder.and(qEAIMessageEntity.eaisvcname.contains(searchName));
if (!StringUtils.isEmpty(searchUseYn))
boolBuilder.and(useYnCondition(qInflowControl.useyn, searchUseYn));
List<Tuple> tupleList = jpaQueryFactory List<Tuple> tupleList = jpaQueryFactory
.select(qInflowControl, qEAIMessageEntity.eaisvcname, qEAIMessageEntity.eaisvcdesc) .select(qInflowControl, qEAIMessageEntity.eaisvcname, qEAIMessageEntity.eaisvcdesc)
.from(qEAIMessageEntity) .from(qEAIMessageEntity)
.leftJoin(qInflowControl) .leftJoin(qInflowControl)
.on(qInflowControl.id.type.eq(INTERFACE_TYPE_INFLOW).and(qInflowControl.id.name.eq(qEAIMessageEntity.eaisvcname))) .on(qInflowControl.id.type.eq(INTERFACE_TYPE_INFLOW).and(qInflowControl.id.name.eq(qEAIMessageEntity.eaisvcname)))
.where(qEAIMessageEntity.eaisvcname.contains(searchName)) .where(boolBuilder)
.orderBy(qEAIMessageEntity.eaisvcname.asc()) .orderBy(qEAIMessageEntity.eaisvcname.asc())
.offset(pageable.getOffset()) .offset(pageable.getOffset())
.limit(pageable.getPageSize()) .limit(pageable.getPageSize())
@@ -145,7 +163,9 @@ public class InflowControlService extends AbstractDataService<InflowControl, Inf
long totalCount = jpaQueryFactory long totalCount = jpaQueryFactory
.select(qEAIMessageEntity.eaisvcname.count()) .select(qEAIMessageEntity.eaisvcname.count())
.from(qEAIMessageEntity) .from(qEAIMessageEntity)
.where(qEAIMessageEntity.eaisvcname.contains(searchName)) .leftJoin(qInflowControl)
.on(qInflowControl.id.type.eq(INTERFACE_TYPE_INFLOW).and(qInflowControl.id.name.eq(qEAIMessageEntity.eaisvcname)))
.where(boolBuilder)
.fetchOne(); .fetchOne();
return new PageImpl<>(dtoList, pageable, totalCount); return new PageImpl<>(dtoList, pageable, totalCount);
} }
@@ -199,19 +219,24 @@ public class InflowControlService extends AbstractDataService<InflowControl, Inf
return toDto(qClientEntity, qInflowControl, tuple); return toDto(qClientEntity, qInflowControl, tuple);
} }
public Page<InflowControlServiceDto> findAllForClient(Pageable pageable, String searchName) { public Page<InflowControlServiceDto> findAllForClient(Pageable pageable, String searchName, String searchUseYn) {
JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager); JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
QInflowControl qInflowControl = QInflowControl.inflowControl; QInflowControl qInflowControl = QInflowControl.inflowControl;
QClientEntity qClientEntity = QClientEntity.clientEntity; QClientEntity qClientEntity = QClientEntity.clientEntity;
BooleanBuilder boolBuilder = new BooleanBuilder();
boolBuilder.and(qClientEntity.clientname.contains(searchName));
if (!StringUtils.isEmpty(searchUseYn))
boolBuilder.and(useYnCondition(qInflowControl.useyn, searchUseYn));
List<Tuple> tupleList = jpaQueryFactory List<Tuple> tupleList = jpaQueryFactory
.select(qInflowControl, qClientEntity.clientid, qClientEntity.clientname) .select(qInflowControl, qClientEntity.clientid, qClientEntity.clientname)
.from(qClientEntity) .from(qClientEntity)
.leftJoin(qInflowControl) .leftJoin(qInflowControl)
.on(qInflowControl.id.type.eq(CLIENT_TYPE_INFLOW) .on(qInflowControl.id.type.eq(CLIENT_TYPE_INFLOW)
.and(qInflowControl.id.name.eq(qClientEntity.clientid))) .and(qInflowControl.id.name.eq(qClientEntity.clientid)))
.where(qClientEntity.clientname.contains(searchName)) .where(boolBuilder)
.orderBy(qClientEntity.clientname.asc()) .orderBy(qClientEntity.clientname.asc())
.offset(pageable.getOffset()) .offset(pageable.getOffset())
.limit(pageable.getPageSize()) .limit(pageable.getPageSize())
@@ -225,7 +250,10 @@ public class InflowControlService extends AbstractDataService<InflowControl, Inf
long totalCount = jpaQueryFactory long totalCount = jpaQueryFactory
.select(qClientEntity.clientname.count()) .select(qClientEntity.clientname.count())
.from(qClientEntity) .from(qClientEntity)
.where(qClientEntity.clientname.contains(searchName)) .leftJoin(qInflowControl)
.on(qInflowControl.id.type.eq(CLIENT_TYPE_INFLOW)
.and(qInflowControl.id.name.eq(qClientEntity.clientid)))
.where(boolBuilder)
.fetchOne(); .fetchOne();
return new PageImpl<>(dtoList, pageable, totalCount); return new PageImpl<>(dtoList, pageable, totalCount);
} }
@@ -266,11 +294,20 @@ public class InflowControlService extends AbstractDataService<InflowControl, Inf
QInflowControlLog qInflowConfrolLog = QInflowControlLog.inflowControlLog; QInflowControlLog qInflowConfrolLog = QInflowControlLog.inflowControlLog;
QInflowControlGroup qinflowControlGroup = QInflowControlGroup.inflowControlGroup; QInflowControlGroup qinflowControlGroup = QInflowControlGroup.inflowControlGroup;
String startTime = StringUtils.defaultIfEmpty(uiSearch.getSearchStartTime(), "000000");
String endTime = StringUtils.defaultIfEmpty(uiSearch.getSearchEndTime(), "235959");
BooleanBuilder predicate = new BooleanBuilder(); BooleanBuilder predicate = new BooleanBuilder();
predicate predicate
.and(qInflowConfrolLog.id.msgdpstyms .and(qInflowConfrolLog.id.msgdpstyms
.between(uiSearch.getSearchStartDate() + "000000000", .between(uiSearch.getSearchStartDate() + startTime + "000",
uiSearch.getSearchEndDate() + "235959999")); uiSearch.getSearchEndDate() + endTime + "999"));
if (!StringUtils.isEmpty(uiSearch.getSearchInstanceName()))
predicate.and(qInflowConfrolLog.eaisevrinstncname.contains(uiSearch.getSearchInstanceName()));
if (!StringUtils.isEmpty(uiSearch.getSearchApiName()))
predicate.and(qInflowConfrolLog.eaisvcname.contains(uiSearch.getSearchApiName()));
if (!StringUtils.isEmpty(uiSearch.getSearchAdapterGroupName()))
predicate.and(qInflowConfrolLog.adptrbzwkgroupname.contains(uiSearch.getSearchAdapterGroupName()));
return getJPAQueryFactory() return getJPAQueryFactory()
.select( .select(
@@ -126,6 +126,15 @@ public class EAILogRepositoryImpl implements EAILogRepository {
query.where(qeaiLog.eaisvcname.containsIgnoreCase(eaiLogSearch.getSearchEaiSvcName())); query.where(qeaiLog.eaisvcname.containsIgnoreCase(eaiLogSearch.getSearchEaiSvcName()));
} }
if (StringUtils.isNotBlank(eaiLogSearch.getSearchEaiSvcDesc())) {
QEAIMessageEntity qeaiMessageEntity = QEAIMessageEntity.eAIMessageEntity;
JPAQuery<String> subQuery = new JPAQueryFactory(em)
.select(qeaiMessageEntity.eaisvcname)
.from(qeaiMessageEntity)
.where(qeaiMessageEntity.eaisvcdesc.containsIgnoreCase(eaiLogSearch.getSearchEaiSvcDesc()));
query.where(qeaiLog.eaisvcname.in(subQuery));
}
if (StringUtils.isNotBlank(eaiLogSearch.getSearchKeyMgtMsgCtnt())) { if (StringUtils.isNotBlank(eaiLogSearch.getSearchKeyMgtMsgCtnt())) {
query.where(qeaiLog.keymgtmsgctnt.contains(eaiLogSearch.getSearchKeyMgtMsgCtnt())); query.where(qeaiLog.keymgtmsgctnt.contains(eaiLogSearch.getSearchKeyMgtMsgCtnt()));
} }
@@ -1,18 +1,122 @@
package com.eactive.eai.rms.ext.djb.apistatus; package com.eactive.eai.rms.ext.djb.apistatus;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.eactive.apim.portal.common.entity.Auditable;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentTimeline;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
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.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeService;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
/**
* API 상태 탐지 결과를 개발자포탈 API Status 화면용 장애 데이터로 반영한다.
*
* <p>탐지 자체는 {@link ApiStatusService} 가 수행하며, 본 서비스는 그 결과를
* {@code DJB_APISTATUS_INCIDENT} / {@code _API} / {@code _TIMELINE} + {@code PTL_NOTICE} 로 기록한다.</p>
*
* <p>자동 등록 여부와 공지 게시 시점은 PTL_PROPERTY
* ({@code Portal} / {@code djb.apistatus.auto-incident-mode}) 로 정한다.
* {@code OFF}면 자동 등록을 하지 않고, {@code DRAFT}(기본)면 관리자가 공지사항 관리에서
* 검수·게시해야 개발자포탈에 노출되며, {@code PUBLISH}면 탐지 즉시 노출된다.
* {@code OFF} 여도 이미 열려있는 장애의 복구 처리(ERROR_END/DELAY_END)는 계속 동작한다.</p>
*
* <p>점검(CONTROL_*) 은 관리자가 공지사항으로 등록한 점검 일정이 원천이다
* ({@code ApiStatusRepository.findApiStatusEvents} 가 INCIDENT 기간을 읽어 CTRL_YN 을 판정).
* 따라서 CONTROL 이벤트를 다시 기록하면 순환이 되므로 기록하지 않는다.</p>
*/
@Service @Service
@Transactional @Transactional(transactionManager = "transactionManagerForEMS", rollbackFor = Exception.class)
public class ApiStatusDetectionService { public class ApiStatusDetectionService {
private static final Logger log = LoggerFactory.getLogger(ApiStatusDetectionService.class); private static final Logger log = LoggerFactory.getLogger(ApiStatusDetectionService.class);
public static final String EVENT_CONTROL_START = "CONTROL_START";
public static final String EVENT_CONTROL_END = "CONTROL_END";
public static final String EVENT_ERROR_START = "ERROR_START";
public static final String EVENT_ERROR_END = "ERROR_END";
public static final String EVENT_DELAY_START = "DELAY_START";
public static final String EVENT_DELAY_END = "DELAY_END";
/** INTERFACE_ID 멱등 윈도우 (분) */
private static final int IDEMPOTENT_WINDOW_MINUTES = 5;
/** 종결 상태 - 신규 탐지 병합 대상에서 제외 */
private static final List<IncidentState> CLOSED_STATES =
Collections.unmodifiableList(Arrays.asList(IncidentState.RESOLVED, IncidentState.CANCELED));
private static final String AUTHOR_SYSTEM = "SYSTEM";
private static final String DETECTED_BY_AUTO = "AUTO";
private static final String NOTICE_TYPE_INCIDENT = "3";
private static final int NOTICE_SUBJECT_MAX_LENGTH = 255;
/** 자동 탐지 공지 처리 방식 프로퍼티 그룹 (개발자포탈과 동일 그룹을 쓴다) */
public static final String PROPERTY_GROUP = "Portal";
/** 자동 탐지 장애/지연의 공지 처리 방식 - OFF / DRAFT / PUBLISH */
public static final String KEY_AUTO_INCIDENT_MODE = "djb.apistatus.auto-incident-mode";
/** 자동 등록 안 함. 신규 등록·병합 모두 하지 않는다 (복구 처리는 계속 동작) */
public static final String MODE_OFF = "OFF";
/** 초안만 생성. 관리자가 공지사항 관리에서 검수·게시해야 개발자포탈에 노출된다 */
public static final String MODE_DRAFT = "DRAFT";
/** 초안 생성 후 곧바로 게시. 검수 없이 개발자포탈에 즉시 노출된다 */
public static final String MODE_PUBLISH = "PUBLISH";
/** 오탐이 그대로 고객에게 노출되지 않도록 기본은 검수(DRAFT) */
private static final String DEFAULT_AUTO_INCIDENT_MODE = MODE_DRAFT;
@Autowired
private DjbApistatusIncidentRepository incidentRepository;
@Autowired
private DjbApistatusIncidentApiRepository incidentApiRepository;
@Autowired
private DjbApistatusIncidentTimelineRepository incidentTimelineRepository;
@Autowired
private PortalNoticeService portalNoticeService;
@Autowired
private ApiStatusRepository apiStatusRepository;
@Autowired
private PortalPropertyService portalPropertyService;
/** 자동 초안 문구 양식 (classpath:apistatus-draft.yml) */
@Autowired
private ApiStatusDraftTemplate draftTemplate;
/** /**
* 이벤트 감지 * 이벤트 감지
* @param event "CONTROL_START" : 점검시작 * @param event "CONTROL_START" : 점검시작
@@ -25,17 +129,561 @@ public class ApiStatusDetectionService {
*/ */
public void detect(String event, List<String> apiIds) { public void detect(String event, List<String> apiIds) {
log.debug("이벤트 감지: {} {}", event, apiIds); log.debug("이벤트 감지: {} {}", event, apiIds);
if (StringUtils.isBlank(event) || apiIds == null || apiIds.isEmpty()) {
return;
} }
// API FSM 내 아직 장애로 남아있는 API 목록 List<String> targets = apiIds.stream()
public void remainDownApiIds() { .filter(StringUtils::isNotBlank)
.distinct()
.sorted()
.collect(Collectors.toList());
if (targets.isEmpty()) {
return;
} }
switch (event) {
case EVENT_ERROR_START:
openIncident(event, IncidentKind.INCIDENT, targets, "에러율 임계 초과", "장애");
break;
case EVENT_DELAY_START:
openIncident(event, IncidentKind.DELAY, targets, "응답시간 임계 초과", "응답지연");
break;
case EVENT_ERROR_END:
case EVENT_DELAY_END:
markRecovered(event, targets);
break;
case EVENT_CONTROL_START:
case EVENT_CONTROL_END:
// 점검은 관리자 등록 공지가 원천 - 재기록하지 않음
log.debug("점검 이벤트는 장애 데이터로 기록하지 않음: {} {}", event, targets);
break;
default:
log.warn("알 수 없는 이벤트: {}", event);
}
}
// 정상 동작 - detect()에서 일괄 처리 (복구 = 점검종료, 장애종료, 지연종료) /**
// public void recovered(String event, String[] apiIds) { * 미종결 장애·지연으로 남아있는 API 목록 (탐지 Job 폴링용)
// */
// } @Transactional(transactionManager = "transactionManagerForEMS", readOnly = true)
public List<String> remainDownApiIds() {
List<DjbApistatusIncident> openIncidents = incidentRepository
.findByKindInAndStateNotInOrderByStartedAtDesc(IncidentKind.DEGRADING, CLOSED_STATES);
if (openIncidents.isEmpty()) {
return Collections.emptyList();
}
List<Long> incidentIds = openIncidents.stream()
.map(DjbApistatusIncident::getIncidentId)
.collect(Collectors.toList());
return incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(incidentIds).stream()
.filter(api -> api.getRecoveredAt() == null)
.map(DjbApistatusIncidentApi::getApiId)
.distinct()
.sorted()
.collect(Collectors.toList());
}
// ────────────── 장애 발생 ──────────────
/**
* 신규 이슈 등록. 지연(DELAY)은 공지를 만들지 않고, 장애(INCIDENT)만 공지 초안/게시를 동반한다.
*/
private void openIncident(String event, IncidentKind kind, List<String> apiIds,
String summary, String titleKeyword) {
// 0) OFF 면 신규 등록·병합·타임라인 기록을 모두 건너뛴다.
// 이미 열려있는 이슈의 복구(markRecovered)는 계속 동작해야 하므로 여기서만 막는다.
String mode = resolveAutoIncidentMode();
if (MODE_OFF.equals(mode)) {
log.info("자동 이슈 등록 비활성(OFF) - 기록하지 않음: {} {}", event, apiIds);
return;
}
boolean autoPublish = MODE_PUBLISH.equals(mode);
LocalDateTime now = LocalDateTime.now();
String interfaceId = makeInterfaceId(event, apiIds, now);
// 1) 동일 윈도우 재탐지 - 타임라인만 남기고 종료
Optional<DjbApistatusIncident> duplicated = incidentRepository.findByInterfaceId(interfaceId);
if (duplicated.isPresent()) {
appendTimeline(duplicated.get().getIncidentId(), null,
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now);
return;
}
// 2) 이미 열려있는 자동 등록 이슈가 해당 API 를 포함하면 영향 API 만 병합
// (관리자가 직접 작성한 장애 공지는 건드리지 않는다)
// 지연으로 열린 건에 장애가 얹히는 경우가 있어 지연·장애를 함께 찾는다.
List<DjbApistatusIncident> openIncidents = incidentRepository
.findOpenByApiIds(IncidentKind.DEGRADING, DETECTED_BY_AUTO, CLOSED_STATES, apiIds);
if (!openIncidents.isEmpty()) {
mergeIntoOpenIncident(openIncidents.get(0), event, kind, apiIds, now, autoPublish);
return;
}
// 3) 신규 이슈 생성. 장애는 공지도 같이 만들고, PUBLISH 면 검수 없이 바로 게시한다.
Map<String, String> apiNames = resolveApiNames(apiIds);
String title = buildTitle(titleKeyword, apiIds, apiNames);
PortalNotice notice = kind == IncidentKind.DELAY
? null : createNotice(title, summary, apiIds, apiNames, now, autoPublish);
DjbApistatusIncident incident = new DjbApistatusIncident();
incident.setKind(kind);
incident.setState(IncidentState.INVESTIGATING);
incident.setTitle(title);
incident.setSummary(summary);
incident.setStartedAt(now);
incident.setDetectedBy(DETECTED_BY_AUTO);
incident.setInterfaceId(interfaceId);
incident.setNoticeId(notice == null ? null : notice.getId());
// 개발자포탈 노출 조건: 공지가 있으면 DRAFT_YN='N' + USE_YN='Y', 없으면(지연) DRAFT_YN='N' 만
incident.setDraftYn(autoPublish ? "N" : "Y");
incident.setFixYn("N");
stampAudit(incident, now);
DjbApistatusIncident saved;
try {
saved = incidentRepository.saveAndFlush(incident);
} catch (DataIntegrityViolationException e) {
// INTERFACE_ID UNIQUE 충돌 - 동시 탐지. 만든 공지를 정리하고 타임라인만 남긴다
log.info("이슈 중복 감지 (interfaceId={}) - 기존 이슈에 타임라인 추가", interfaceId, e);
if (notice != null) {
portalNoticeService.deleteById(notice.getId());
}
incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing ->
appendTimeline(existing.getIncidentId(), null,
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now));
return;
}
saveIncidentApis(saved.getIncidentId(), apiIds, apiNames, now);
// 타임라인도 고객 화면에 그대로 나가므로 인터페이스 ID 대신 게시 API 명 + 건수로 적는다
appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING,
draftTemplate.text(ApiStatusDraftTemplate.TL_DETECTED,
"summary", summary, "event", event, "affected", affectedSummary(apiIds, apiNames)), now);
log.info("자동 이슈 등록: incidentId={}, kind={}, noticeId={}, mode={}, apis={}",
saved.getIncidentId(), kind, notice == null ? "-" : notice.getId(),
autoPublish ? MODE_PUBLISH : MODE_DRAFT, apiIds);
}
/**
* 자동 탐지 장애/지연의 공지 처리 방식.
*
* <p>PTL_PROPERTY({@code Portal} / {@code djb.apistatus.auto-incident-mode}) 를 읽는다.
* 최초 접근 시 기본값으로 row 가 자동 생성된다.</p>
*
* <ul>
* <li>{@code OFF} - 자동 등록 안 함 (복구 처리는 계속)</li>
* <li>{@code DRAFT}(기본) - 초안만 생성, 관리자 검수 후 게시</li>
* <li>{@code PUBLISH} - 초안 생성 후 즉시 게시</li>
* </ul>
*
* <p>조회 실패나 알 수 없는 값은 검수 쪽(DRAFT)으로 떨어뜨린다 - 오탐이 고객 화면에
* 그대로 나가는 것도, 진짜 장애를 아예 안 남기는 것도 기본 동작으로는 위험하기 때문이다.</p>
*/
private String resolveAutoIncidentMode() {
try {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_AUTO_INCIDENT_MODE, DEFAULT_AUTO_INCIDENT_MODE,
"API 상태 자동 탐지 공지 처리 방식 - OFF: 자동 등록 안 함, "
+ "DRAFT: 초안만 생성(관리자 검수 후 게시), PUBLISH: 초안 생성 후 즉시 게시");
String normalized = StringUtils.trimToEmpty(value);
if (MODE_OFF.equalsIgnoreCase(normalized)) {
return MODE_OFF;
}
if (MODE_PUBLISH.equalsIgnoreCase(normalized)) {
return MODE_PUBLISH;
}
if (!MODE_DRAFT.equalsIgnoreCase(normalized)) {
log.warn("알 수 없는 자동 탐지 공지 처리 방식 '{}' - 기본값 {} 적용", value, DEFAULT_AUTO_INCIDENT_MODE);
}
return MODE_DRAFT;
} catch (Exception e) {
log.warn("자동 탐지 공지 처리 방식 조회 실패 - 기본값 {} 적용", DEFAULT_AUTO_INCIDENT_MODE, e);
return DEFAULT_AUTO_INCIDENT_MODE;
}
}
private void mergeIntoOpenIncident(DjbApistatusIncident incident, String event, IncidentKind kind,
List<String> apiIds, LocalDateTime now, boolean autoPublish) {
Long incidentId = incident.getIncidentId();
// 지연으로 열린 건에 장애가 얹히면 종류를 올린다 (강등은 하지 않는다)
boolean escalated = escalateIfNeeded(incident, event, kind, now, autoPublish);
// DRAFT 로 만들어진 뒤 PUBLISH 로 설정이 바뀐 경우, 이 이슈가 닫힐 때까지 모든 탐지가
// 여기로 병합돼 아무것도 게시되지 않는다. 병합 시점에 게시 상태를 맞춰준다.
if (autoPublish) {
publishIfPending(incident);
}
Set<String> mapped = incidentApiRepository.findByIncidentIdOrderByApiId(incidentId).stream()
.map(DjbApistatusIncidentApi::getApiId)
.collect(Collectors.toCollection(HashSet::new));
List<String> added = apiIds.stream()
.filter(apiId -> !mapped.contains(apiId))
.collect(Collectors.toList());
if (added.isEmpty()) {
// 격상을 남긴 턴에는 같은 시각·같은 이벤트로 "재탐지 신호 수신"이 겹쳐 찍힌다.
// 격상 줄이 더 구체적인 정보이므로 재탐지 줄은 생략한다.
if (!escalated) {
appendTimeline(incidentId, null,
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now);
}
log.debug("기존 이슈에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}, escalated={}",
incidentId, apiIds, escalated);
return;
}
Map<String, String> addedNames = resolveApiNames(added);
saveIncidentApis(incidentId, added, addedNames, now);
appendTimeline(incidentId, null,
draftTemplate.text(ApiStatusDraftTemplate.TL_API_ADDED,
"event", event, "affected", affectedSummary(added, addedNames)), now);
log.info("기존 이슈에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added);
}
/**
* 지연(DELAY)으로 열린 이슈에 장애(ERROR_START)가 감지되면 종류를 장애로 올린다.
*
* <p>지연은 공지가 없으므로 격상 시점에 공지를 새로 만들어 붙인다. 반대 방향(장애→지연)은
* 하지 않는다 - 한 번 장애로 인지된 구간을 나중에 완화해서 표기하면 이력이 왜곡된다.</p>
*
* @return 실제로 격상하여 타임라인을 남겼으면 true
*/
private boolean escalateIfNeeded(DjbApistatusIncident incident, String event, IncidentKind kind,
LocalDateTime now, boolean autoPublish) {
if (kind != IncidentKind.INCIDENT || incident.getKind() != IncidentKind.DELAY) {
return false;
}
List<String> apiIds = incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(DjbApistatusIncidentApi::getApiId)
.collect(Collectors.toList());
Map<String, String> apiNames = resolveApiNames(apiIds);
String title = buildTitle("장애", apiIds, apiNames);
String summary = "에러율 임계 초과";
incident.setKind(IncidentKind.INCIDENT);
incident.setTitle(title);
incident.setSummary(summary);
if (incident.getNoticeId() == null) {
// 이미 노출 중이던(DRAFT_YN='N') 지연이 격상되는데 공지가 미게시로 붙으면
// 보이던 이슈가 사라진다. 그 경우엔 공지도 게시 상태로 만든다.
boolean publishNotice = autoPublish || "N".equals(incident.getDraftYn());
PortalNotice notice = createNotice(title, summary, apiIds, apiNames, now, publishNotice);
incident.setNoticeId(notice.getId());
}
incident.setLastModifiedBy(AUTHOR_SYSTEM);
incident.setLastModifiedDate(now);
incidentRepository.save(incident);
appendTimeline(incident.getIncidentId(), null,
draftTemplate.text(ApiStatusDraftTemplate.TL_ESCALATED, "event", event), now);
log.info("지연 → 장애 격상: incidentId={}, noticeId={}",
incident.getIncidentId(), incident.getNoticeId());
return true;
}
/**
* 아직 초안으로 남아있는 자동 탐지 이슈를 게시 상태로 올린다 (PUBLISH 모드에서만 호출).
*
* <p>대상은 자동 등록(DETECTED_BY='AUTO') 건뿐이다. 관리자가 공지사항 관리에서 손댄 건은
* DETECTED_BY 가 'MANUAL' 로 바뀌므로 여기 오지 않는다.</p>
*
* <p>지연은 연결 공지가 없으므로 DRAFT_YN 만 바꾸면 노출된다.</p>
*/
private void publishIfPending(DjbApistatusIncident incident) {
if (!"Y".equals(incident.getDraftYn())) {
return;
}
incident.setDraftYn("N");
incidentRepository.save(incident);
if (StringUtils.isNotBlank(incident.getNoticeId())) {
portalNoticeService.findById(incident.getNoticeId())
.filter(notice -> !"Y".equals(notice.getUseYn()))
.ifPresent(notice -> {
notice.setUseYn("Y");
portalNoticeService.save(notice);
});
}
log.info("자동 탐지 이슈 뒤늦은 게시: incidentId={}, noticeId={}",
incident.getIncidentId(), incident.getNoticeId());
}
// ────────────── 복구 ──────────────
private void markRecovered(String event, List<String> apiIds) {
LocalDateTime now = LocalDateTime.now();
List<DjbApistatusIncidentApi> candidates =
incidentApiRepository.findByApiIdInAndRecoveredAtIsNull(apiIds);
if (candidates.isEmpty()) {
log.debug("복구 대상 장애 없음: {} {}", event, apiIds);
return;
}
// 복구는 이벤트 종류를 구분하지 않는다. ERROR_END 하나로 그 API 의 지연 건까지 닫는다
// (상태머신상 E → N 으로 돌아가므로 DELAY_END 가 따로 오지 않는다).
Map<Long, DjbApistatusIncident> openIncidents = incidentRepository
.findByKindInAndStateNotInOrderByStartedAtDesc(IncidentKind.DEGRADING, CLOSED_STATES).stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<Long, List<String>> recoveredByIncident = new HashMap<>();
for (DjbApistatusIncidentApi api : candidates) {
if (!openIncidents.containsKey(api.getIncidentId())) {
continue;
}
api.setRecoveredAt(now);
api.setLastModifiedBy(AUTHOR_SYSTEM);
api.setLastModifiedDate(now);
incidentApiRepository.save(api);
recoveredByIncident.computeIfAbsent(api.getIncidentId(), key -> new ArrayList<>())
.add(api.getApiId());
}
if (recoveredByIncident.isEmpty()) {
log.debug("복구 대상 장애 없음 (미종결 장애 미포함): {} {}", event, apiIds);
return;
}
for (Map.Entry<Long, List<String>> entry : recoveredByIncident.entrySet()) {
Long incidentId = entry.getKey();
List<String> recoveredApis = entry.getValue();
DjbApistatusIncident incident = openIncidents.get(incidentId);
boolean allRecovered = incidentApiRepository.countByIncidentIdAndRecoveredAtIsNull(incidentId) == 0;
IncidentState nextState = allRecovered ? IncidentState.RESOLVED : IncidentState.MONITORING;
if (incident.getState() != nextState) {
incident.setPreviousState(incident.getState());
incident.setState(nextState);
}
if (allRecovered) {
incident.setEndAt(now);
}
incident.setLastModifiedBy(AUTHOR_SYSTEM);
incident.setLastModifiedDate(now);
incidentRepository.save(incident);
String body = draftTemplate.text(
allRecovered ? ApiStatusDraftTemplate.TL_RECOVERED_ALL
: ApiStatusDraftTemplate.TL_RECOVERED_PART,
"event", event,
"affected", affectedSummary(recoveredApis, resolveApiNames(recoveredApis)));
appendTimeline(incidentId, nextState, body, now);
log.info("자동 복구 처리: incidentId={}, state={}, apis={}", incidentId, nextState, recoveredApis);
}
}
// ────────────── 내부 헬퍼 ──────────────
private String makeInterfaceId(String event, List<String> sortedApiIds, LocalDateTime now) {
long windowSeconds = IDEMPOTENT_WINDOW_MINUTES * 60L;
long flooredEpoch = now.atZone(ZoneId.systemDefault()).toEpochSecond()
/ windowSeconds * windowSeconds;
String joined = String.join(",", sortedApiIds);
String interfaceId = event + ":" + joined + ":" + flooredEpoch;
// INTERFACE_ID 는 VARCHAR2(200) - 초과 시 API 목록을 해시로 축약
if (interfaceId.length() > 200) {
interfaceId = event + ":#" + Integer.toHexString(joined.hashCode())
+ ":" + sortedApiIds.size() + ":" + flooredEpoch;
}
return interfaceId;
}
private Map<String, String> resolveApiNames(List<String> apiIds) {
Map<String, String> names = new HashMap<>();
try {
for (ApiNameRow row : apiStatusRepository.findApiNames(apiIds)) {
names.put(row.getEaisvcname(), row.getEaisvcdesc());
}
} catch (Exception e) {
log.warn("API 명 조회 실패 - API ID 를 그대로 사용", e);
}
return names;
}
private String apiLabel(String apiId, Map<String, String> apiNames) {
return StringUtils.defaultIfBlank(apiNames.get(apiId), apiId);
}
/**
* 영향 인터페이스를 개발자포탈 게시 여부로 가른 결과.
* 게시된 것(= API)만 이름을 드러내고 나머지는 건수로만 표기한다.
*/
private static final class ApiSplit {
private final List<String> published;
private final int hiddenCount;
private ApiSplit(List<String> published, int hiddenCount) {
this.published = published;
this.hiddenCount = hiddenCount;
}
}
/** 게시 인터페이스 판정. 조회에 실패하면 전부 비게시로 보아 ID·명칭 노출을 막는다. */
private ApiSplit splitPublished(List<String> apiIds) {
if (apiIds == null || apiIds.isEmpty()) {
return new ApiSplit(Collections.emptyList(), 0);
}
Set<String> publishedIds;
try {
publishedIds = new HashSet<>(apiStatusRepository.findPublishedApiIds(apiIds));
} catch (Exception e) {
// 실패 시 노출하는 쪽으로 기울면 내부 인터페이스가 고객 화면에 새어 나간다
log.warn("게시 API 판정 실패 - 전부 GW 인터페이스로 묶어 표기", e);
publishedIds = Collections.emptySet();
}
List<String> published = new ArrayList<>();
for (String apiId : apiIds) {
if (publishedIds.contains(apiId)) {
published.add(apiId);
}
}
return new ApiSplit(published, apiIds.size() - published.size());
}
/** "GW 인터페이스 3건" - 게시되지 않은 인터페이스 묶음 표기 */
private String hiddenLabel(int hiddenCount) {
return draftTemplate.text(ApiStatusDraftTemplate.GW_LABEL, "count", String.valueOf(hiddenCount));
}
/**
* 영향 인터페이스 한 줄 요약. 게시 API 는 이름으로, 나머지는 건수로 묶는다.
* 예) {@code 계좌조회, 이체 · GW 인터페이스 2건}
*/
private String affectedSummary(List<String> apiIds, Map<String, String> apiNames) {
ApiSplit split = splitPublished(apiIds);
List<String> parts = new ArrayList<>();
for (String apiId : split.published) {
parts.add(apiLabel(apiId, apiNames));
}
String names = String.join(", ", parts);
if (split.hiddenCount == 0) {
return names;
}
return names.isEmpty() ? hiddenLabel(split.hiddenCount) : names + " · " + hiddenLabel(split.hiddenCount);
}
/**
* 공지 제목. 게시 API 가 하나도 없으면 인터페이스 명을 쓰지 않고 건수로만 적는다.
*/
private String buildTitle(String titleKeyword, List<String> apiIds, Map<String, String> apiNames) {
ApiSplit split = splitPublished(apiIds);
if (split.published.isEmpty()) {
return draftTemplate.text(ApiStatusDraftTemplate.TITLE_HIDDEN_ONLY,
"gw", hiddenLabel(apiIds.size()), "keyword", titleKeyword);
}
String first = apiLabel(split.published.get(0), apiNames);
if (apiIds.size() > 1) {
return draftTemplate.text(ApiStatusDraftTemplate.TITLE_MULTI,
"first", first, "rest", String.valueOf(apiIds.size() - 1), "keyword", titleKeyword);
}
return draftTemplate.text(ApiStatusDraftTemplate.TITLE_SINGLE, "first", first, "keyword", titleKeyword);
}
/**
* 자동 탐지 장애 공지 생성.
*
* <p>본문에는 관리자가 검수하며 채울 항목(발생 원인·영향 범위·조치 내용·예상 복구 시간)을
* 미리 넣어 둔다. 초안이면 작성 힌트를 함께 남기고, 이미 게시되는 경우엔 고객에게
* 그대로 노출되므로 "확인 중입니다" 로 채워 둔다.</p>
*
* @param publish true 면 게시(USE_YN='Y'), false 면 초안(USE_YN='N')
*/
private PortalNotice createNotice(String title, String summary, List<String> apiIds,
Map<String, String> apiNames, LocalDateTime now, boolean publish) {
StringBuilder detail = new StringBuilder();
detail.append(draftTemplate.text(publish
? ApiStatusDraftTemplate.BODY_LEAD_PUBLISH
: ApiStatusDraftTemplate.BODY_LEAD_DRAFT,
"summary", summary));
// 게시된 API 만 이름으로 적고(인터페이스 ID 는 넣지 않는다) 나머지는 건수로 묶는다.
// 공지 본문은 저장 시점에 굳는 HTML 이라 나중에 사용자별로 가릴 수 없다.
ApiSplit split = splitPublished(apiIds);
detail.append("<p><b>")
.append(draftTemplate.text(ApiStatusDraftTemplate.BODY_AFFECTED_HEADING))
.append("</b></p><ul>");
for (String apiId : split.published) {
detail.append("<li>").append(apiLabel(apiId, apiNames)).append("</li>");
}
if (split.hiddenCount > 0) {
detail.append("<li>").append(hiddenLabel(split.hiddenCount)).append("</li>");
}
detail.append("</ul>");
for (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) {
detail.append("<p><b>").append(section.getTitle()).append("</b></p>");
// 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
detail.append("<p>").append(publish
? draftTemplate.text(ApiStatusDraftTemplate.BODY_FILLED_TEXT)
: draftTemplate.text(ApiStatusDraftTemplate.BODY_DRAFT_TEXT, "hint", section.getHint()))
.append("</p>");
}
PortalNotice notice = new PortalNotice();
notice.setId(null);
notice.setNoticeSubject(StringUtils.abbreviate(title, NOTICE_SUBJECT_MAX_LENGTH));
notice.setNoticeDetail(detail.toString());
notice.setNoticeType(NOTICE_TYPE_INCIDENT);
notice.setUseYn(publish ? "Y" : "N");
notice.setFixYn("N");
notice.setReadCount(0L);
notice.setInquirerName(AUTHOR_SYSTEM);
notice.setCreatedBy(AUTHOR_SYSTEM);
notice.setCreatedDate(now);
portalNoticeService.save(notice);
return notice;
}
private void saveIncidentApis(Long incidentId, Collection<String> apiIds,
Map<String, String> apiNames, LocalDateTime now) {
List<DjbApistatusIncidentApi> rows = new ArrayList<>();
for (String apiId : apiIds) {
DjbApistatusIncidentApi api = new DjbApistatusIncidentApi();
api.setIncidentId(incidentId);
api.setApiId(apiId);
api.setApiName(apiNames.get(apiId));
stampAudit(api, now);
rows.add(api);
}
if (!rows.isEmpty()) {
incidentApiRepository.saveAll(rows);
}
}
private void appendTimeline(Long incidentId, IncidentState stateAfter, String body, LocalDateTime now) {
DjbApistatusIncidentTimeline timeline = new DjbApistatusIncidentTimeline();
timeline.setIncidentId(incidentId);
timeline.setEventAt(now);
timeline.setStateAfter(stateAfter);
timeline.setBody(StringUtils.abbreviate(body, 4000));
timeline.setAuthorType(AUTHOR_SYSTEM);
timeline.setVisibleYn("Y");
stampAudit(timeline, now);
incidentTimelineRepository.save(timeline);
}
/**
* 스케줄러 스레드는 로그인 세션이 없어 AuditorAware 가 비어있다. CREATED_BY 는 NOT NULL 이므로 직접 채운다.
*/
private void stampAudit(Auditable entity, LocalDateTime now) {
entity.setCreatedBy(AUTHOR_SYSTEM);
entity.setCreatedDate(now);
}
} }
@@ -0,0 +1,203 @@
package com.eactive.eai.rms.ext.djb.apistatus;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/**
* API Status 자동 탐지 초안 양식({@code classpath:apistatus-draft.yml}).
*
* <p>자동 생성 공지의 제목·본문·타임라인 문구를 코드가 아닌 설정으로 관리한다.
* admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없으므로 SnakeYAML 로 직접 읽는다
* ({@code <context:property-placeholder>} 는 properties 전용이고, 검수 섹션이 리스트라
* properties 로는 인덱스 키로 흩어진다).</p>
*
* <p>파일이 없거나 파싱에 실패하면 {@link #DEFAULTS} 로 동작한다 - 문구 설정 문제로
* 장애 탐지 자체가 멈추면 안 되기 때문이다. 개별 키가 비어도 같은 이유로 기본값으로 떨어진다.</p>
*/
@Component
public class ApiStatusDraftTemplate {
private static final Logger log = LoggerFactory.getLogger(ApiStatusDraftTemplate.class);
private static final String RESOURCE = "apistatus-draft.yml";
// ---- 키 ----
public static final String GW_LABEL = "gw-label";
public static final String TITLE_SINGLE = "title.single";
public static final String TITLE_MULTI = "title.multi";
public static final String TITLE_HIDDEN_ONLY = "title.hidden-only";
public static final String BODY_LEAD_PUBLISH = "body.lead-publish";
public static final String BODY_LEAD_DRAFT = "body.lead-draft";
public static final String BODY_AFFECTED_HEADING = "body.affected-heading";
public static final String BODY_FILLED_TEXT = "body.filled-text";
public static final String BODY_DRAFT_TEXT = "body.draft-text";
public static final String TL_DETECTED = "timeline.detected";
public static final String TL_API_ADDED = "timeline.api-added";
public static final String TL_REDETECTED = "timeline.redetected";
public static final String TL_ESCALATED = "timeline.escalated";
public static final String TL_RECOVERED_ALL = "timeline.recovered-all";
public static final String TL_RECOVERED_PART = "timeline.recovered-part";
/** yml 을 못 읽었을 때 쓰는 기본 문구 (기존 하드코딩과 동일) */
private static final Map<String, String> DEFAULTS;
static {
Map<String, String> defaults = new LinkedHashMap<>();
defaults.put(GW_LABEL, "GW 인터페이스 {count}건");
defaults.put(TITLE_SINGLE, "[자동감지] {first} API {keyword}");
defaults.put(TITLE_MULTI, "[자동감지] {first} 외 {rest}종 API {keyword}");
defaults.put(TITLE_HIDDEN_ONLY, "[자동감지] {gw} {keyword}");
defaults.put(BODY_LEAD_PUBLISH, "<p>{summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.</p>");
defaults.put(BODY_LEAD_DRAFT, "<p>{summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>");
defaults.put(BODY_AFFECTED_HEADING, "영향 API");
defaults.put(BODY_FILLED_TEXT, "확인 중입니다.");
defaults.put(BODY_DRAFT_TEXT, "[작성 필요] {hint}");
defaults.put(TL_DETECTED, "{summary} 자동 감지 ({event})\n영향 API: {affected}");
defaults.put(TL_API_ADDED, "영향 API 추가 감지 ({event})\n{affected}");
defaults.put(TL_REDETECTED, "재탐지 신호 수신 ({event})");
defaults.put(TL_ESCALATED, "지연에서 장애로 격상 ({event})");
defaults.put(TL_RECOVERED_ALL, "전체 복구 확인 ({event})\n{affected}");
defaults.put(TL_RECOVERED_PART, "일부 복구 확인 ({event})\n{affected}");
DEFAULTS = Collections.unmodifiableMap(defaults);
}
/** 관리자가 검수 시 채워 넣을 항목 */
public static final class Section {
private final String title;
private final String hint;
public Section(String title, String hint) {
this.title = title;
this.hint = hint;
}
public String getTitle() {
return title;
}
public String getHint() {
return hint;
}
}
private static final List<Section> DEFAULT_SECTIONS = Collections.unmodifiableList(new ArrayList<Section>() {
private static final long serialVersionUID = 1L;
{
add(new Section("발생 원인", "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"));
add(new Section("영향 범위", "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"));
add(new Section("조치 내용", "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"));
add(new Section("예상 복구 시간", "예) 복구 완료 시 실제 시각으로 갱신"));
}
});
/** 점(.) 으로 평탄화한 문구 맵 */
private Map<String, String> texts = DEFAULTS;
private List<Section> sections = DEFAULT_SECTIONS;
@PostConstruct
public void load() {
try (InputStream in = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
if (in == null) {
log.info("{} 없음 - 기본 초안 양식 사용", RESOURCE);
return;
}
// SafeConstructor - 임의 클래스 생성(CVE-2022-1471 계열)을 막고 스칼라/맵/리스트만 읽는다
Object root = new Yaml(new SafeConstructor(new LoaderOptions())).load(in);
Map<String, Object> draft = asMap(asMap(root).get("draft"));
if (draft.isEmpty()) {
log.warn("{} 에 draft 섹션이 없음 - 기본 초안 양식 사용", RESOURCE);
return;
}
Map<String, String> loaded = new LinkedHashMap<>(DEFAULTS);
flatten("", draft, loaded);
this.texts = loaded;
this.sections = readSections(draft);
log.info("초안 양식 로딩 완료: {} (섹션 {}개)", RESOURCE, sections.size());
} catch (Exception e) {
// 문구 설정 문제로 장애 탐지가 멈추면 안 된다
log.warn("{} 로딩 실패 - 기본 초안 양식 사용", RESOURCE, e);
this.texts = DEFAULTS;
this.sections = DEFAULT_SECTIONS;
}
}
/**
* 문구 조회 + 치환. {@code args} 는 {키, 값, 키, 값...} 순으로 넘긴다.
* 정의되지 않은 치환자는 그대로 남겨 어떤 키가 빠졌는지 화면에서 드러나게 한다.
*/
public String text(String key, String... args) {
String template = texts.get(key);
if (template == null) {
template = DEFAULTS.get(key);
}
if (template == null) {
log.warn("정의되지 않은 초안 문구 키: {}", key);
return "";
}
String result = template;
for (int i = 0; i + 1 < args.length; i += 2) {
result = result.replace("{" + args[i] + "}", args[i + 1] == null ? "" : args[i + 1]);
}
return result;
}
public List<Section> getSections() {
return sections;
}
// ────────────── 내부 헬퍼 ──────────────
/** 중첩 맵을 {@code body.lead-draft} 형태의 평탄 키로 편다. 리스트(sections)는 별도로 읽는다. */
@SuppressWarnings("unchecked")
private void flatten(String prefix, Map<String, Object> source, Map<String, String> target) {
for (Map.Entry<String, Object> entry : source.entrySet()) {
String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey();
Object value = entry.getValue();
if (value instanceof Map) {
flatten(key, (Map<String, Object>) value, target);
} else if (value != null && !(value instanceof List)) {
target.put(key, String.valueOf(value));
}
}
}
private List<Section> readSections(Map<String, Object> draft) {
Object body = asMap(draft.get("body")).get("sections");
if (!(body instanceof List)) {
return DEFAULT_SECTIONS;
}
List<Section> result = new ArrayList<>();
for (Object item : (List<?>) body) {
Map<String, Object> section = asMap(item);
Object title = section.get("title");
if (title == null) {
continue;
}
Object hint = section.get("hint");
result.add(new Section(String.valueOf(title), hint == null ? "" : String.valueOf(hint)));
}
return result.isEmpty() ? DEFAULT_SECTIONS : Collections.unmodifiableList(result);
}
@SuppressWarnings("unchecked")
private Map<String, Object> asMap(Object value) {
return value instanceof Map ? (Map<String, Object>) value : Collections.<String, Object>emptyMap();
}
}
@@ -4,7 +4,11 @@ import java.util.List;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.Collection;
import com.eactive.eai.data.jpa.BaseRepository; import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiErrorRateRow;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus; import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent; import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent;
@@ -24,7 +28,8 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
@Query(nativeQuery = true, value = @Query(nativeQuery = true, value =
" SELECT EAISVCNAME" " SELECT EAISVCNAME"
+ " , (SELECT EAISVCDESC FROM TSEAIHE01 WHERE A.EAISVCNAME = EAISVCNAME) AS EAISVCDESC" + " , (SELECT EAISVCDESC FROM TSEAIHE01 WHERE A.EAISVCNAME = EAISVCNAME) AS EAISVCDESC"
+ " , PREV_STATUS_CODE " // 따옴표 없는 PREV_STATUS_CODE 는 Oracle 이 대문자로 올려 getPrevStatusCode() 와 매칭되지 않는다 (null 반환)
+ " , PREV_STATUS_CODE AS \"prevStatusCode\""
+ " , EVENT" + " , EVENT"
+ " FROM (" + " FROM ("
+ " SELECT EAISVCNAME" + " SELECT EAISVCNAME"
@@ -39,14 +44,17 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
+ " FROM (" + " FROM ("
+ " SELECT A.EAISVCNAME" + " SELECT A.EAISVCNAME"
+ " , NVL(B.STATUS_CODE, 'N') AS STATUS_CODE" + " , NVL(B.STATUS_CODE, 'N') AS STATUS_CODE"
// 점검(KIND='MAINTENANCE')만 CTRL_YN 대상이다. KIND 를 안 걸면 관리자가 시작~종료 시각을
// 넣어 등록한 장애 공지까지 '점검중'으로 오판해 CONTROL_START 가 발생한다.
+ " , NVL(( SELECT MAX('Y') FROM EMSAPP.DJB_APISTATUS_INCIDENT_API X" + " , NVL(( SELECT MAX('Y') FROM EMSAPP.DJB_APISTATUS_INCIDENT_API X"
+ " WHERE EXISTS (SELECT 1 FROM EMSAPP.DJB_APISTATUS_INCIDENT Y WHERE X.INCIDENT_ID = Y.INCIDENT_ID " + " WHERE EXISTS (SELECT 1 FROM EMSAPP.DJB_APISTATUS_INCIDENT Y WHERE X.INCIDENT_ID = Y.INCIDENT_ID "
+ " AND Y.KIND = 'MAINTENANCE'"
+ " AND SYSTIMESTAMP BETWEEN STARTED_AT AND END_AT) " + " AND SYSTIMESTAMP BETWEEN STARTED_AT AND END_AT) "
+ " AND A.EAISVCNAME = X.API_ID),'N') AS CTRL_YN " + " AND A.EAISVCNAME = X.API_ID),'N') AS CTRL_YN "
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'" + " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
+ " WHEN (TOTAL - SUCCESS) * 100 / TOTAL > :errorRate THEN 'Y'" + " WHEN (TOTAL - SUCCESS) * 100 / TOTAL >= :errorRate THEN 'Y'"
+ " ELSE 'N' END" + " ELSE 'N' END"
+ " FROM (SELECT SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) AS TOTAL" + " FROM (SELECT NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
+ " , SUM(SUCCESS_CNT) AS SUCCESS" + " , SUM(SUCCESS_CNT) AS SUCCESS"
+ " FROM API_STATS_MINUTE" + " FROM API_STATS_MINUTE"
+ " WHERE A.EAISVCNAME = API_NAME" + " WHERE A.EAISVCNAME = API_NAME"
@@ -54,9 +62,9 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:errorRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')" + " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:errorRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS ERR_YN" + " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS ERR_YN"
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'" + " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
+ " WHEN RESP_SUM / TOTAL > :delayAvgRespTime THEN 'Y'" + " WHEN RESP_SUM / TOTAL >= :delayAvgRespTime THEN 'Y'"
+ " ELSE 'N' END" + " ELSE 'N' END"
+ " FROM (SELECT SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) AS TOTAL" + " FROM (SELECT NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
+ " , SUM(SUCCESS_CNT) AS SUCCESS" + " , SUM(SUCCESS_CNT) AS SUCCESS"
+ " , SUM((SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) * AVG_RESP_TIME) AS RESP_SUM" + " , SUM((SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) * AVG_RESP_TIME) AS RESP_SUM"
+ " FROM API_STATS_MINUTE" + " FROM API_STATS_MINUTE"
@@ -73,4 +81,88 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
@Param("delayRangeMinute") int delayRangeMinute, @Param("delayRangeMinute") int delayRangeMinute,
@Param("delayAvgRespTime") int delayAvgRespTime @Param("delayAvgRespTime") int delayAvgRespTime
); );
/**
* 판정 구간에 트래픽이 있는 API 의 오류율·지연 판정값. 개발 모드(-Deai.systemmode=D) 진단 로그 전용.
*
* <p>{@code findApiStatusEvents} 는 임계값을 넘고 <b>상태 전이까지 일어난</b> API 만 돌려주므로
* "오류는 나는데 왜 장애로 안 잡히나", "느린데 왜 지연이 아닌가" 를 볼 수 없다. 이 쿼리는
* 임계값·현재 상태와 무관하게 집계값을 그대로 보여준다.</p>
*
* <p>오류가 0건이어도 지연 판정값은 확인해야 하므로 오류 발생 여부로 거르지 않는다.</p>
*
* <p>컬럼 alias 는 반드시 <b>따옴표로 감싼 camelCase</b> 로 둔다. 인터페이스 프로젝션은 tuple
* alias 를 getter 프로퍼티명으로 찾는데, 따옴표가 없으면 Oracle 이 alias 를 대문자로 올려
* {@code STATUS_CODE} 와 {@code statusCode} 가 매칭되지 않아 <b>조용히 null</b> 이 된다
* (단일 단어는 대소문자만 달라 우연히 매칭된다).</p>
*/
@Query(nativeQuery = true, value =
" SELECT N.API_NAME AS \"eaisvcname\""
+ " , NVL(B.STATUS_CODE,'N') AS \"statusCode\""
+ " , NVL(S.TOTAL,0) AS \"total\""
+ " , NVL(S.SUCCESS,0) AS \"success\""
+ " , CASE WHEN NVL(S.TOTAL,0) > 0 THEN ROUND((S.TOTAL - S.SUCCESS) * 100 / S.TOTAL, 2) END AS \"errorRate\""
+ " , CASE WHEN NVL(S.TOTAL,0) > 0 THEN ROUND(S.RESP_SUM / S.TOTAL, 2) END AS \"avgRespTime\""
+ " , NVL(D.TOTAL,0) AS \"delayTotal\""
+ " , CASE WHEN NVL(D.TOTAL,0) > 0 THEN ROUND(D.RESP_SUM / D.TOTAL, 2) END AS \"delayAvgRespTime\""
// 두 창 중 넓은 쪽으로 대상 API 를 먼저 뽑는다. 오류 창만 기준으로 잡으면 오류 창에 거래가
// 없을 때 지연 판정값까지 통째로 사라진다 (창 길이가 서로 다르다)
+ " FROM (SELECT DISTINCT API_NAME"
+ " FROM API_STATS_MINUTE"
+ " WHERE TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(GREATEST(:errorRangeMinute, :delayRangeMinute),'MINUTE'),'YYYYMMDDHH24MI')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI')) N"
+ " LEFT OUTER JOIN (SELECT API_NAME"
+ " , NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
+ " , NVL(SUM(SUCCESS_CNT),0) AS SUCCESS"
+ " , NVL(SUM((SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) * AVG_RESP_TIME),0) AS RESP_SUM"
+ " FROM API_STATS_MINUTE"
+ " WHERE TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:errorRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI')"
+ " GROUP BY API_NAME) S ON N.API_NAME = S.API_NAME"
// 지연은 판정 구간이 따로다(delayRangeMinute). 오류 구간 평균으로 비교하면 실제 판정과 어긋난다
+ " LEFT OUTER JOIN (SELECT API_NAME"
+ " , NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
+ " , NVL(SUM((SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) * AVG_RESP_TIME),0) AS RESP_SUM"
+ " FROM API_STATS_MINUTE"
+ " WHERE TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:delayRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI')"
+ " GROUP BY API_NAME) D ON N.API_NAME = D.API_NAME"
+ " LEFT OUTER JOIN API_STATUS B ON N.API_NAME = B.EAISVCNAME"
// 두 창 중 어느 쪽이든 거래가 있으면 찍는다 (오류 0건, 오류 창 무거래 모두 포함)
+ " WHERE NVL(S.TOTAL,0) > 0 OR NVL(D.TOTAL,0) > 0"
+ " ORDER BY CASE WHEN NVL(S.TOTAL,0) > 0 THEN (S.TOTAL - S.SUCCESS) / S.TOTAL ELSE -1 END DESC, N.API_NAME")
List<ApiErrorRateRow> findApiErrorRates(
@Param("errorRangeMinute") int errorRangeMinute,
@Param("delayRangeMinute") int delayRangeMinute
);
/**
* EAI 서비스명 조회. 장애/점검 영향 API 의 API_NAME 캐시용.
*/
@Query(nativeQuery = true, value =
" SELECT EAISVCNAME, EAISVCDESC FROM TSEAIHE01 WHERE EAISVCNAME IN (:apiIds)")
List<ApiNameRow> findApiNames(@Param("apiIds") Collection<String> apiIds);
/**
* 개발자포탈에 게시되는 인터페이스(= API) 만 추린다.
*
* <p>GW 인터페이스는 전부 탐지 대상이지만 개발자포탈에 노출되는 것은 그 일부다.
* 자동 초안(제목·공지 본문·타임라인)에 게시되지 않은 인터페이스 ID 가 그대로 박히면
* 고객 화면에 내부 인터페이스가 드러나므로, 여기서 걸러 나머지는 건수로만 표기한다.</p>
*
* <p>판정 기준은 포털 "오픈 API" 목록과 같은 두 조건이다 - 노출 중인 API 그룹에 편성돼 있고
* PTL_API_SPEC_INFO 에 스펙이 있을 것. 역할·소속에 따른 사용자별 공개 범위는 admin 에
* 사용자 컨텍스트가 없어 적용하지 않는다(= 포털 화면이 한 번 더 좁힌다).</p>
*/
@Query(nativeQuery = true, value =
" SELECT DISTINCT GA.API_ID"
+ " FROM API_GROUP_API GA"
+ " JOIN API_GROUP G ON G.ID = GA.API_GROUP_ID"
+ " WHERE GA.API_ID IN (:apiIds)"
+ " AND G.DISPLAY_YN = '1'"
+ " AND EXISTS (SELECT 1 FROM EMSAPP.PTL_API_SPEC_INFO S WHERE S.API_ID = GA.API_ID)")
List<String> findPublishedApiIds(@Param("apiIds") Collection<String> apiIds);
} }
@@ -1,5 +1,7 @@
package com.eactive.eai.rms.ext.djb.apistatus; package com.eactive.eai.rms.ext.djb.apistatus;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -13,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional;
import com.eactive.apim.portal.template.entity.MessageCode; import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.eai.rms.data.entity.man.role.Role; import com.eactive.eai.rms.data.entity.man.role.Role;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiErrorRateRow;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus; import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent; import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent;
import com.eactive.eai.rms.ext.djb.ums.UmsManager; import com.eactive.eai.rms.ext.djb.ums.UmsManager;
@@ -36,11 +39,18 @@ public class ApiStatusService {
public void updateApiStatus(HashMap<String, String> param) { public void updateApiStatus(HashMap<String, String> param) {
int errorRate = Integer.parseInt(param.get("errorRate"));
int errorRangeMinute = Integer.parseInt(param.get("errorRangeMinute"));
int delayRangeMinute = Integer.parseInt(param.get("delayRangeMinute"));
int delayAvgRespTime = Integer.parseInt(param.get("delayAvgRespTime"));
logErrorRates(errorRate, errorRangeMinute, delayRangeMinute, delayAvgRespTime);
List<ApiStatusEvent> list = apiStatusRepository.findApiStatusEvents( List<ApiStatusEvent> list = apiStatusRepository.findApiStatusEvents(
Integer.parseInt(param.get("errorRate")), errorRate,
Integer.parseInt(param.get("errorRangeMinute")), errorRangeMinute,
Integer.parseInt(param.get("delayRangeMinute")), delayRangeMinute,
Integer.parseInt(param.get("delayAvgRespTime")) delayAvgRespTime
); );
HashMap<String, List<String>> eventMap = new HashMap<>(); HashMap<String, List<String>> eventMap = new HashMap<>();
@@ -88,6 +98,85 @@ public class ApiStatusService {
} }
/**
* 판정 구간에 거래가 있는 API 의 오류율·지연 판정값을 debug 로그로 남긴다.
* 개발 모드(-Deai.systemmode=D) 에서만 동작한다.
*
* <p>상태 전이 조건을 못 넘긴 API 는 {@code findApiStatusEvents} 결과에 나오지 않아
* "오류는 나는데 장애로 안 잡힌다", "느린데 지연이 아니다" 를 로그로 확인할 수 없다.
* 임계값·현재 상태와 무관하게 집계값을 그대로 찍어 판정 결과와 대조할 수 있게 한다.</p>
*
* <p>오류 0건이어도 지연 판정값은 봐야 하므로 거래만 있으면 무조건 찍는다.</p>
*
* <p>진단 목적이므로 조회 실패가 상태 판정 자체를 막아서는 안 된다 - 예외는 삼킨다.</p>
*/
private void logErrorRates(int errorRate, int errorRangeMinute, int delayRangeMinute, int delayAvgRespTime) {
if (!isDevMode() || !log.isDebugEnabled()) {
return;
}
try {
List<ApiErrorRateRow> rows = apiStatusRepository.findApiErrorRates(errorRangeMinute, delayRangeMinute);
if (rows.isEmpty()) {
log.debug("상태 판정 진단: 최근 {}분(오류)/{}분(지연) 거래 발생 API 없음",
errorRangeMinute, delayRangeMinute);
return;
}
StringBuilder sb = new StringBuilder();
sb.append("상태 판정 진단: 장애 임계 ").append(errorRate).append("% 초과(최근 ").append(errorRangeMinute)
.append("분), 지연 임계 ").append(delayAvgRespTime).append("ms 초과(최근 ").append(delayRangeMinute)
.append("분), 대상 ").append(rows.size()).append("");
for (ApiErrorRateRow row : rows) {
sb.append("\n - ").append(row.getEaisvcname())
.append(" 현재상태=").append(row.getStatusCode())
.append("\n 오류율=").append(row.getErrorRate()).append("%")
.append(" (임계 대비 ").append(ratioOf(row.getErrorRate(), errorRate)).append("%)")
.append(", 총 ").append(row.getTotal()).append("건, 성공 ").append(row.getSuccess()).append("")
.append("").append(thresholdMark(row.getErrorRate(), errorRate))
.append("\n 평균응답=").append(row.getDelayAvgRespTime()).append("ms")
.append(" (임계 대비 ").append(ratioOf(row.getDelayAvgRespTime(), delayAvgRespTime)).append("%)")
.append(", 총 ").append(row.getDelayTotal()).append("")
.append("").append(thresholdMark(row.getDelayAvgRespTime(), delayAvgRespTime));
}
log.debug(sb.toString());
} catch (Exception e) {
log.debug("상태 판정 진단 로그 생성 실패", e);
}
}
/**
* 임계값 대비 비율(%). 100% 면 임계값과 같은 값이고, 판정은 '초과'(&gt;) 라 100% 는 아직 안 걸린다.
*/
private String ratioOf(BigDecimal value, int threshold) {
if (value == null || threshold <= 0) {
return "-";
}
return value.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(threshold), 2, RoundingMode.HALF_UP)
.toPlainString();
}
/**
* 임계 초과 여부 표시. 판정 조건이 '초과'(&gt;) 이므로 임계값과 같은 값은 초과가 아니다.
*/
private String thresholdMark(BigDecimal value, int threshold) {
if (value == null) {
return "데이터없음";
}
return value.compareTo(BigDecimal.valueOf(threshold)) > 0 ? "임계초과" : "임계이하";
}
/**
* 개발 모드 여부. -Deai.systemmode=D 로 기동한 경우에만 진단 로그를 남긴다.
*/
private boolean isDevMode() {
return "D".equalsIgnoreCase(System.getProperty("eai.systemmode", ""));
}
private String resolveStatusCode(String event) { private String resolveStatusCode(String event) {
switch (event) { switch (event) {
case "CONTROL_START": return "C"; //점검 case "CONTROL_START": return "C"; //점검
@@ -0,0 +1,115 @@
package com.eactive.eai.rms.ext.djb.common;
import lombok.Data;
import org.hibernate.validator.constraints.NotEmpty;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.Pattern;
@Data
public class DjbProperty {
// SMS 2차 인증
@DjbPropertyValue(
key = "djb.sms_auth.enabled",
defaultValue = "true",
description = "SMS 2차 인증 사용 여부. true 시 휴대폰번호 없는 사용자는 ID/PW 로그인 불가")
private boolean smsAuthEnabled = false;
@DjbPropertyValue(
key = "djb.sms_auth.mode",
defaultValue = "real",
description = "인증번호 생성 모드: real(랜덤), hhmm00(현재시각+00), fixed(고정값)")
@Pattern(regexp = "^(real|hhmm00|fixed)$", message = "djb.sms_auth.mode는 real, hhmm00, fixed 중 하나여야 합니다.")
private String smsAuthMode = "real";
@DjbPropertyValue(
key = "djb.sms_auth.fixed_value",
defaultValue = "654321",
description = "mode가 fixed일 때 사용할 고정 인증번호 (6자리 숫자)")
@Pattern(regexp = "^\\d{6}$", message = "djb.sms_auth.fixed_value는 6자리 숫자여야 합니다.")
private String smsAuthFixedValue = "654321";
@DjbPropertyValue(
key = "djb.api.allow_ip_list",
description = "API 접근 허용 IP 목록 (콤마(,)로 구분된 IP들, 예: 192.168.0.1, 192.168.1.*,127.*.*.*",
defaultValue = "10.15.106.*, 10.14.8.*, 10.15.8.*, 192.168.240.*, 127.0.0.1, 192.168.132.*, 192.168.131.*"
)
private String apiAllowIpList = "10.15.106.*, 10.14.8.*, 10.15.8.*, 192.168.240.*, 127.0.0.1, 192.168.132.*, 192.168.131.*";
public String toJsonString(boolean isPretty) {
return isPretty
? new GsonBuilder().setPrettyPrinting().create().toJson(this)
: new Gson().toJson(this);
}
// 기본 프로퍼티 목록을 콘솔로 출력 (가이드용)
public static void main(String[] args) {
System.out.println("| Key | Default Value | Description |");
System.out.println("|-----|---------------|-------------|");
for (java.lang.reflect.Field field : DjbProperty.class.getDeclaredFields()) {
DjbPropertyValue annotation = field.getAnnotation(DjbPropertyValue.class);
if (annotation != null) {
String key = annotation.key();
String defaultValue = annotation.defaultValue();
String description = annotation.description();
System.out.printf("| %s | %s | %s |%n", key, defaultValue, description);
}
}
}
}
@@ -0,0 +1,92 @@
package com.eactive.eai.rms.ext.djb.common;
import lombok.extern.slf4j.Slf4j;
import java.util.function.Function;
/**
* DjbProperty 싱글톤 홀더.
*
* <p>모든 곳에서 {@code DjbPropertyHolder.get()}으로 현재 DjbProperty에 접근합니다.
* eapim-admin에서 초기화 시 {@code initialize()}를 호출하고,
* MonitoringContext.refresh() 시 {@code reload()}를 호출하여 값을 갱신합니다.</p>
*
* <p>사용 예시:</p>
* <pre>
* // 값 접근
* String url = DjbPropertyHolder.get().getUmsHostUrl();
*
* // 초기화 (eapim-admin에서)
* DjbPropertyInjector injector = new DjbPropertyInjector(service, "Monitoring");
* DjbPropertyHolder.initialize(injector::inject);
*
* // reload (MonitoringContext.refresh() 시)
* DjbPropertyHolder.reload();
*
* // 테스트용
* DjbPropertyHolder.setForTest(mockProperty);
* </pre>
*/
@Slf4j
public class DjbPropertyHolder {
private static volatile DjbProperty instance = new DjbProperty();
private static volatile Function<DjbProperty, DjbProperty> injector;
private DjbPropertyHolder() {
// 유틸리티 클래스
}
/**
* 현재 DjbProperty 인스턴스를 반환합니다.
*/
public static DjbProperty get() {
return instance;
}
/**
* 초기화 함수를 등록하고 최초 로딩을 수행합니다.
* eapim-admin 부팅 시 MonitoringContextImpl.init()에서 호출됩니다.
*
* @param injectorFn DjbProperty를 DB에서 로딩하는 함수 (DjbPropertyInjector::inject)
*/
public static void initialize(Function<DjbProperty, DjbProperty> injectorFn) {
injector = injectorFn;
reload();
log.info("DjbPropertyHolder 초기화 완료");
}
/**
* DB에서 프로퍼티를 다시 로딩합니다.
* MonitoringContext.refresh() 호출 시 함께 호출됩니다.
*/
public static void reload() {
if (injector != null) {
try {
instance = injector.apply(new DjbProperty());
log.info("DjbProperty reload 완료");
} catch (Exception e) {
log.error("DjbProperty reload 실패: {}", e.getMessage(), e);
}
} else {
log.debug("DjbPropertyHolder: injector가 설정되지 않음 (테스트 환경일 수 있음)");
}
}
/**
* 테스트용 - 직접 인스턴스를 설정합니다.
*/
public static void setForTest(DjbProperty testProperty) {
instance = testProperty;
log.debug("DjbProperty 테스트 인스턴스 설정");
}
/**
* 테스트용 - 기본값으로 초기화합니다.
*/
public static void resetForTest() {
instance = new DjbProperty();
injector = null;
log.debug("DjbPropertyHolder 테스트 초기화");
}
}
@@ -1,10 +1,8 @@
package com.eactive.ext.kjb.util; package com.eactive.eai.rms.ext.djb.common;
import com.eactive.eai.rms.data.entity.man.monitoringProperty.MonitoringProperty; import com.eactive.eai.rms.data.entity.man.monitoringProperty.MonitoringProperty;
import com.eactive.eai.rms.data.entity.man.monitoringProperty.MonitoringPropertyId; import com.eactive.eai.rms.data.entity.man.monitoringProperty.MonitoringPropertyId;
import com.eactive.eai.rms.data.entity.man.monitoringProperty.service.MonitoringPropertyService; import com.eactive.eai.rms.data.entity.man.monitoringProperty.service.MonitoringPropertyService;
import com.eactive.ext.kjb.common.KjbProperty;
import com.eactive.ext.kjb.common.KjbPropertyValue;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -12,52 +10,49 @@ import java.lang.reflect.Field;
import java.util.Optional; import java.util.Optional;
@Slf4j @Slf4j
public class KjbPropertyInjector { public class DjbPropertyInjector {
private final MonitoringPropertyService monitoringPropertyService; private final MonitoringPropertyService monitoringPropertyService;
private final String groupId; private final String groupId;
public KjbPropertyInjector(MonitoringPropertyService monitoringPropertyService, String groupId) { public DjbPropertyInjector(MonitoringPropertyService monitoringPropertyService, String groupId) {
this.monitoringPropertyService = monitoringPropertyService; this.monitoringPropertyService = monitoringPropertyService;
this.groupId = groupId; this.groupId = groupId;
} }
/** /**
* KjbProperty의 URL 값들이 설정되어 있는지 확인 * DjbProperty의 URL 값들이 설정되어 있는지 확인
* @param property 검사할 KjbProperty 객체 * @param property 검사할 DjbProperty 객체
* @return URL 값들이 모두 설정되어 있으면 true * @return URL 값들이 모두 설정되어 있으면 true
*/ */
public boolean isUrlConfigured(KjbProperty property) { public boolean isUrlConfigured(DjbProperty property) {
if (property == null) return false; if (property == null) return false;
// 주요 URL 값들 하나라도 설정되어 있으면 true return StringUtils.isNotEmpty(property.getApiAllowIpList());
return StringUtils.isNotEmpty(property.getEaiBatchUrl())
|| StringUtils.isNotEmpty(property.getUmsHostUrl())
|| StringUtils.isNotEmpty(property.getObpUrl());
} }
/** /**
* URL 값이 비어있을 경우 DB에서 재로딩 시도 * URL 값이 비어있을 경우 DB에서 재로딩 시도
* @param property 기존 KjbProperty 객체 * @param property 기존 DjbProperty 객체
* @return 재로딩된 KjbProperty 객체 (재로딩 실패 기존 객체 반환) * @return 재로딩된 DjbProperty 객체 (재로딩 실패 기존 객체 반환)
*/ */
public KjbProperty reloadIfUrlEmpty(KjbProperty property) { public DjbProperty reloadIfUrlEmpty(DjbProperty property) {
if (property == null) { if (property == null) {
return inject(new KjbProperty()); return inject(new DjbProperty());
} }
if (!isUrlConfigured(property)) { if (!isUrlConfigured(property)) {
log.info("KjbProperty URL 값이 비어있어 DB에서 재로딩 시도"); log.info("DjbProperty URL 값이 비어있어 DB에서 재로딩 시도");
try { try {
KjbProperty reloaded = inject(new KjbProperty()); DjbProperty reloaded = inject(new DjbProperty());
if (isUrlConfigured(reloaded)) { if (isUrlConfigured(reloaded)) {
log.info("KjbProperty 재로딩 성공"); log.info("DjbProperty 재로딩 성공");
return reloaded; return reloaded;
} else { } else {
log.warn("KjbProperty 재로딩 후에도 URL 값이 비어있음"); log.warn("DjbProperty 재로딩 후에도 URL 값이 비어있음");
} }
} catch (Exception e) { } catch (Exception e) {
log.error("KjbProperty 재로딩 실패: {}", e.getMessage(), e); log.error("DjbProperty 재로딩 실패: {}", e.getMessage(), e);
} }
} }
@@ -68,7 +63,7 @@ public class KjbPropertyInjector {
Class<?> clazz = property.getClass(); Class<?> clazz = property.getClass();
for (Field field : clazz.getDeclaredFields()) { for (Field field : clazz.getDeclaredFields()) {
KjbPropertyValue anno = field.getAnnotation(KjbPropertyValue.class); DjbPropertyValue anno = field.getAnnotation(DjbPropertyValue.class);
if (anno != null) { if (anno != null) {
String key = anno.key(); String key = anno.key();
@@ -0,0 +1,14 @@
package com.eactive.eai.rms.ext.djb.common;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface DjbPropertyValue {
String key();
String defaultValue() default "";
String description() default "";
}
@@ -1,8 +1,9 @@
package com.eactive.eai.rms.ext.djb.job; package com.eactive.eai.rms.ext.djb.job;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import javax.persistence.EntityManager; import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext; import javax.persistence.PersistenceContext;
@@ -28,7 +29,8 @@ import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsHour;
/** /**
* API 로그 테이블을 시간별 통계로 집계하는 배치 작업 * API 로그 테이블을 시간별 통계로 집계하는 배치 작업
* TSEAILGXX → API_STATS_HOUR * TSEAILGXX → API_STATS_HOUR
* 실행 주기: 매시 00:10 (당일 데이터 집계) * 실행 주기: 매시 00:10, 직전 1시간(예: 14:10 실행 시 13:00~13:59:59) 데이터 집계
* (0시대 실행 시 전날 23:00~23:59:59 집계)
*/ */
@Component @Component
public class ApiStatsHourlyAggregationJob implements Job { public class ApiStatsHourlyAggregationJob implements Job {
@@ -59,11 +61,9 @@ public class ApiStatsHourlyAggregationJob implements Job {
DataSourceType dataType = DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW); DataSourceType dataType = DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW);
DataSourceContextHolder.setDataSourceType(dataType); DataSourceContextHolder.setDataSourceType(dataType);
try { try {
LocalDate targetDate = LocalDate.now(); // 직전 1시간(예: 14:10 실행 -> 13:00~13:59:59). 0시대 실행 시 자동으로 전날 23시로 롤오버됨
if (LocalTime.now().isBefore(LocalTime.of(1, 0))) { LocalDateTime targetHour = LocalDateTime.now().minusHours(1).truncatedTo(ChronoUnit.HOURS);
targetDate = targetDate.minusDays(1); selfJob.executeManual(targetHour);
}
selfJob.executeManual(targetDate);
} catch (Exception e) { } catch (Exception e) {
throw new JobExecutionException(e); throw new JobExecutionException(e);
} finally { } finally {
@@ -72,22 +72,38 @@ public class ApiStatsHourlyAggregationJob implements Job {
} }
/** /**
* 수동 실행 메서드 (날짜 지정) * 수동 실행 메서드 (날짜 지정, 하루 전체 재집계 - 관리자 화면 백필용)
*/ */
@Transactional @Transactional
public int executeManual(LocalDate targetDate) { public int executeManual(LocalDate targetDate) {
LocalDateTime rangeStart = targetDate.atStartOfDay();
return aggregate(rangeStart, rangeStart.plusDays(1));
}
/**
* 수동/스케줄 실행 메서드 (시각 지정, 해당 시각이 속한 1시간만 집계)
*/
@Transactional
public int executeManual(LocalDateTime targetHour) {
LocalDateTime rangeStart = targetHour.truncatedTo(ChronoUnit.HOURS);
return aggregate(rangeStart, rangeStart.plusHours(1));
}
private int aggregate(LocalDateTime rangeStart, LocalDateTime rangeEnd) {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
log.info("=== 시간별 통계 집계 작업 시작 === 대상: {}", targetDate); log.info("=== 시간별 통계 집계 작업 시작 === 대상: {} ~ {}", rangeStart, rangeEnd);
String searchDate = targetDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); DateTimeFormatter tsFormat = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
String logTableName = CommonUtil.getLogTable(searchDate, true); String logTableName = CommonUtil.getLogTable(rangeStart.format(DateTimeFormatter.ofPattern("yyyyMMdd")), true);
String rangeStartStr = rangeStart.format(tsFormat) + "000";
String rangeEndStr = rangeEnd.format(tsFormat) + "000";
// 1. 기존 데이터 삭제 // 1. 대상 구간(rangeStart ~ rangeEnd)의 기존 데이터 삭제 (다른 시간대 데이터는 보존)
QApiStatsHour q = QApiStatsHour.apiStatsHour; QApiStatsHour q = QApiStatsHour.apiStatsHour;
long deletedCount = apiStatsHourService.getJPAQueryFactory() long deletedCount = apiStatsHourService.getJPAQueryFactory()
.delete(q) .delete(q)
.where(q.statTime.goe(targetDate.atStartOfDay()) .where(q.statTime.goe(rangeStart)
.and(q.statTime.lt(targetDate.plusDays(1).atStartOfDay()))) .and(q.statTime.lt(rangeEnd)))
.execute(); .execute();
log.info("기존 데이터 삭제: {} 건", deletedCount); log.info("기존 데이터 삭제: {} 건", deletedCount);
@@ -122,18 +138,23 @@ public class ApiStatsHourlyAggregationJob implements Job {
" , MAX(CASE WHEN LOGPRCSSSERNO = '100' THEN GSTATSYSADPTRBZWKGROUPNAME ELSE '' END) AS INBOUND_ADAPTER" + " , MAX(CASE WHEN LOGPRCSSSERNO = '100' THEN GSTATSYSADPTRBZWKGROUPNAME ELSE '' END) AS INBOUND_ADAPTER" +
" , MAX(CASE WHEN LOGPRCSSSERNO = '100' THEN PSVSYSADPTRBZWKGROUPNAME ELSE '' END) AS OUTBOUND_ADAPTER" + " , MAX(CASE WHEN LOGPRCSSSERNO = '100' THEN PSVSYSADPTRBZWKGROUPNAME ELSE '' END) AS OUTBOUND_ADAPTER" +
" , MIN(MSGDPSTYMS) AS DT" + " , MIN(MSGDPSTYMS) AS DT" +
" , MAX(MSGPRCSSYMS) - MIN(MSGDPSTYMS) AS RESP_TIME" + " , ( (TO_DATE(SUBSTR(MAX(MSGPRCSSYMS),1,14),'YYYYMMDDHH24MISS')" +
" - TO_DATE(SUBSTR(MIN(MSGPRCSSYMS),1,14),'YYYYMMDDHH24MISS')) * 86400000" +
" + TO_NUMBER(SUBSTR(MAX(MSGPRCSSYMS),15,3))" +
" - TO_NUMBER(SUBSTR(MIN(MSGPRCSSYMS),15,3))" +
" ) AS RESP_TIME" +
" , MAX(CASE WHEN LOGPRCSSSERNO = '400' THEN LOGPRCSSSERNO ELSE '' END) AS E400" + " , MAX(CASE WHEN LOGPRCSSSERNO = '400' THEN LOGPRCSSSERNO ELSE '' END) AS E400" +
" , MAX(EAIERRCD) AS ERROR_CODE" + " , MAX(EAIERRCD) AS ERROR_CODE" +
" FROM " + logTableName + " FROM " + logTableName +
" WHERE MSGDPSTYMS LIKE :searchDate || '%'" + " WHERE MSGDPSTYMS >= :rangeStart AND MSGDPSTYMS < :rangeEnd" +
" GROUP BY EAISVCSERNO" + " GROUP BY EAISVCSERNO" +
" ) A LEFT OUTER JOIN TSEAICM20 B ON A.ERROR_CODE = B.CODE AND B.CODEGROUP = 'ERRCODE_TIMEOUT' AND B.USEYN = 'Y' " + " ) A LEFT OUTER JOIN TSEAICM20 B ON A.ERROR_CODE = B.CODE AND B.CODEGROUP = 'ERRCODE_TIMEOUT' AND B.USEYN = 'Y' " +
" GROUP BY SUBSTR(DT,1,10), API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, NVL(A.CLIENT_ID, 'NONE')" + " GROUP BY SUBSTR(DT,1,10), API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, NVL(A.CLIENT_ID, 'NONE')" +
" , INBOUND_ADAPTER, OUTBOUND_ADAPTER"; " , INBOUND_ADAPTER, OUTBOUND_ADAPTER";
int savedCount = entityManager.createNativeQuery(sql) int savedCount = entityManager.createNativeQuery(sql)
.setParameter("searchDate", searchDate) .setParameter("rangeStart", rangeStartStr)
.setParameter("rangeEnd", rangeEndStr)
.executeUpdate(); .executeUpdate();
long elapsedTime = System.currentTimeMillis() - startTime; long elapsedTime = System.currentTimeMillis() - startTime;
@@ -1,4 +1,4 @@
package com.eactive.eai.rms.ext.kjb.smsauth; package com.eactive.eai.rms.ext.djb.smsauth;
import com.eactive.apim.portal.user.entity.UserInfo; import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.rms.common.interceptor.InterceptorSkipController; import com.eactive.eai.rms.common.interceptor.InterceptorSkipController;

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