Compare commits
33 Commits
b97b0fcf7b
..
design
| Author | SHA1 | Date | |
|---|---|---|---|
| 63d4d7268c | |||
| c42a278c8e | |||
| 83352e3669 | |||
| 317b8d781d | |||
| 206e72edde | |||
| 34dce308dc | |||
| b3fc5c06bf | |||
| eb74a99a5a | |||
| 38e5a7f13c | |||
| 3b7c2e5a8f | |||
| 47099ec485 | |||
| 6dbf6af3de | |||
| 5585df9133 | |||
| 2c63fa1557 | |||
| d71af34950 | |||
| fbc145d145 | |||
| f36b1a6478 | |||
| 8f03209f8a | |||
| 8743592fde | |||
| a0685c8689 | |||
| c0ae60f738 | |||
| b45a7a162a | |||
| 07fbe1ba54 | |||
| 4916bdf4af | |||
| 35621b5174 | |||
| 6cb98d36bb | |||
| fd229fca43 | |||
| 5e8d08f1af | |||
| e7945233fe | |||
| 815e2bdd04 | |||
| 84e873a08b | |||
| e3b5a44270 | |||
| 2a9694cd85 |
@@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified
|
||||
## 문서
|
||||
|
||||
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
|
||||
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
|
||||
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
|
||||
- **사용자 가이드**: `개발자포탈.md` (한글)
|
||||
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 메뉴 관리 개발 가이드
|
||||
|
||||
포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며,
|
||||
노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다.
|
||||
|
||||
## 구성 요소
|
||||
|
||||
| 구성 | 위치 | 역할 |
|
||||
|---|---|---|
|
||||
| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) |
|
||||
| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) |
|
||||
| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` |
|
||||
| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) |
|
||||
| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) |
|
||||
| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 |
|
||||
| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) |
|
||||
| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) |
|
||||
|
||||
메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer),
|
||||
`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**.
|
||||
|
||||
## menu.yml 스키마
|
||||
|
||||
```yaml
|
||||
portal-menu:
|
||||
items:
|
||||
- id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목)
|
||||
name: "고객지원"
|
||||
group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출)
|
||||
section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속
|
||||
expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of
|
||||
children:
|
||||
- { id: support-faq, name: "FAQ", path: /faq_list }
|
||||
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
|
||||
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
|
||||
```
|
||||
|
||||
- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect).
|
||||
- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스).
|
||||
- 정렬은 yml 나열 순서(기본 배치 sort = index×10).
|
||||
|
||||
## 시딩 규칙 (MenuSeeder)
|
||||
|
||||
1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고,
|
||||
**관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다.
|
||||
구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다.
|
||||
2. **배치**: `PTL_MENU_PLACEMENT` 가 **비어있을 때만** 기본 배치로 최초 시딩.
|
||||
이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨.
|
||||
3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리).
|
||||
4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`.
|
||||
|
||||
## 캐시와 리로드
|
||||
|
||||
- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초).
|
||||
- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload`
|
||||
(admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행).
|
||||
- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback)
|
||||
+ X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`).
|
||||
- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`.
|
||||
|
||||
## 새 메뉴 추가 절차
|
||||
|
||||
**기본 메뉴(코드 배포와 함께)**
|
||||
1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로)
|
||||
2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지)
|
||||
3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인
|
||||
4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) —
|
||||
admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장
|
||||
|
||||
**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload.
|
||||
커스텀 항목은 미배치 시 삭제된다.
|
||||
|
||||
## 로컬 개발 주의
|
||||
|
||||
- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요:
|
||||
`JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"`
|
||||
(미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError).
|
||||
- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로.
|
||||
- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일.
|
||||
|
||||
## 역할(roles.yml) 변경
|
||||
|
||||
- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는
|
||||
admin 권한 선택 체크박스 소스 전용.
|
||||
- 역할 추가 시 `roles.yml` 의 `authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다.
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
|
||||
*
|
||||
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
|
||||
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "API_STATUS")
|
||||
@Data
|
||||
public class GwApiStatus {
|
||||
|
||||
/** EAI 서비스명 */
|
||||
@Id
|
||||
@Column(name = "EAISVCNAME", length = 30)
|
||||
private String eaisvcname;
|
||||
|
||||
/** N 정상 / C 점검 / D 지연 / E 장애 */
|
||||
@Column(name = "STATUS_CODE", length = 1)
|
||||
private String statusCode;
|
||||
|
||||
@Column(name = "MODIFIED_BY", length = 20)
|
||||
private String modifiedBy;
|
||||
|
||||
@Column(name = "MODIFIED_DATE")
|
||||
private LocalDateTime modifiedDate;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwApiStatus;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
|
||||
|
||||
/**
|
||||
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
|
||||
*/
|
||||
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
|
||||
Optional<LocalDateTime> findLastModifiedDate();
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -31,6 +32,7 @@ public class ApiController {
|
||||
private final ApiService apiService;
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final ApiSearchFacade apiSearchFacade;
|
||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||
|
||||
@@ -86,6 +88,8 @@ public class ApiController {
|
||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
||||
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
||||
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
|
||||
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
|
||||
|
||||
return "apps/apis/mainApiList";
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ public class AppServiceFacade {
|
||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
|
||||
Arrays.asList(new ProcessingState(), new RequestedState()));
|
||||
|
||||
// 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
|
||||
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
|
||||
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||
|
||||
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
|
||||
|
||||
@@ -4,11 +4,23 @@ public interface AuthNumberService {
|
||||
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType);
|
||||
|
||||
/**
|
||||
* 기본 TTL 로 발송하되 수신자 이름을 지정한다. 세 번째 인자가 int 인 오버로드(TTL 지정)와 혼동하지 말 것.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, String username);
|
||||
|
||||
/**
|
||||
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
||||
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
||||
|
||||
/**
|
||||
* 수신자 이름을 지정해 인증번호를 발송한다. 메시지 템플릿의 %USER_NAME% 치환에 사용되며,
|
||||
* 회원가입·아이디/비밀번호 찾기처럼 사용자 이름을 알 수 없는 흐름은 "guest" 를 넘긴다.
|
||||
* username 이 비어 있으면 %USER_NAME% 은 치환되지 않고 원문이 그대로 남는다.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username);
|
||||
|
||||
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
||||
}
|
||||
|
||||
@@ -45,19 +45,31 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime);
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, String username) {
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, username);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
||||
return sendRequestAuthNumber(recipientKey, msgType, ttlSeconds, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username) {
|
||||
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
||||
|
||||
validateResendTime(recipientKey);
|
||||
|
||||
String authNumber = generator.generateAuthNumber();
|
||||
|
||||
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType);
|
||||
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType, username);
|
||||
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
||||
|
||||
storage.saveAuthNumber(recipientKey, authNumber,
|
||||
@@ -99,9 +111,13 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
});
|
||||
}
|
||||
|
||||
private MessageRecipient createMessageRecipient(String recipientKey, String msgType) {
|
||||
private MessageRecipient createMessageRecipient(String recipientKey, String msgType, String username) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUserId(recipientKey);
|
||||
// 메시지 템플릿 %USER_NAME% 치환용. 비어 있으면 MessageSendService 가 파라미터 자체를 넣지 않는다.
|
||||
if (username != null && !username.trim().isEmpty()) {
|
||||
recipient.setUsername(username);
|
||||
}
|
||||
if ("SMS".equalsIgnoreCase(msgType)) {
|
||||
recipient.setPhone(recipientKey);
|
||||
} else if ("EMAIL".equalsIgnoreCase(msgType)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.community.notice.dto;
|
||||
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
@@ -53,6 +54,14 @@ public class PortalNoticeDTO {
|
||||
private String state;
|
||||
private String previousState;
|
||||
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
|
||||
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||
private int hiddenApiCount;
|
||||
/** 장애 처리 타임라인. 최신순(내림차순), 공개(visibleYn='Y') 항목만 담는다. */
|
||||
private List<TimelineEntryDTO> timeline = Collections.emptyList();
|
||||
|
||||
public boolean hasTimeline() {
|
||||
return timeline != null && !timeline.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isIncidentType() {
|
||||
return NOTICE_TYPE_INCIDENT.equals(noticeType);
|
||||
|
||||
+7
@@ -2,11 +2,18 @@ package com.eactive.apim.portal.apps.community.notice.repository;
|
||||
|
||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
@EMSDataSource
|
||||
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
|
||||
|
||||
/**
|
||||
* 게시 중인 공지만 골라 한 번에 읽는다. API Status 카드가 연결 공지 본문을 붙일 때 사용한다.
|
||||
* 미게시(USE_YN='N')·삭제된 공지는 결과에서 자연히 빠진다.
|
||||
*/
|
||||
List<PortalNotice> findByIdInAndUseYn(Collection<String> ids, String useYn);
|
||||
}
|
||||
|
||||
|
||||
+31
-4
@@ -5,6 +5,9 @@ import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
|
||||
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
|
||||
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusAssembler;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
|
||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||
@@ -16,8 +19,10 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -29,6 +34,8 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
private final PortalNoticeMapper portalNoticeMapper;
|
||||
private final DjbApistatusIncidentRepository incidentRepository;
|
||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||
private final ApiStatusAssembler apiStatusAssembler;
|
||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||
|
||||
@Override
|
||||
public List<PortalNoticeDTO> getLatestNotices() {
|
||||
@@ -67,11 +74,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
private void populateIncident(PortalNoticeDTO dto) {
|
||||
if (!dto.isIncidentOrMaintenance()) {
|
||||
dto.setAffectedApis(Collections.emptyList());
|
||||
dto.setTimeline(Collections.emptyList());
|
||||
return;
|
||||
}
|
||||
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
|
||||
if (!incidentOpt.isPresent()) {
|
||||
dto.setAffectedApis(Collections.emptyList());
|
||||
dto.setTimeline(Collections.emptyList());
|
||||
return;
|
||||
}
|
||||
DjbApistatusIncident incident = incidentOpt.get();
|
||||
@@ -81,10 +90,28 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
|
||||
|
||||
List<IncidentAffectedApiDTO> apis = incidentApiRepository
|
||||
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
||||
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
|
||||
.collect(Collectors.toList());
|
||||
// 영향 인터페이스 중 개발자포탈에 게시된 API 만 개별 노출한다.
|
||||
// 나머지 GW 인터페이스는 이름·ID 를 감추고 건수로만 알린다.
|
||||
Map<String, String> visibleNames = apiStatusCatalogService.getVisibleApiNames();
|
||||
List<IncidentAffectedApiDTO> apis = new ArrayList<>();
|
||||
int hiddenCount = 0;
|
||||
for (DjbApistatusIncidentApi api :
|
||||
incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId())) {
|
||||
String publishedName = visibleNames.get(api.getApiId());
|
||||
if (publishedName == null) {
|
||||
hiddenCount++;
|
||||
continue;
|
||||
}
|
||||
apis.add(new IncidentAffectedApiDTO(api.getApiId(), publishedName));
|
||||
}
|
||||
dto.setAffectedApis(apis);
|
||||
dto.setHiddenApiCount(hiddenCount);
|
||||
|
||||
// 장애·지연만 타임라인을 붙인다 (점검은 타임라인을 쌓지 않음 — ADR-F15)
|
||||
boolean degrading = incident.getKind() != null && incident.getKind().isDegrading();
|
||||
dto.setTimeline(degrading
|
||||
? apiStatusAssembler.loadTimelines(Collections.singletonList(incident.getIncidentId()))
|
||||
.getOrDefault(incident.getIncidentId(), Collections.emptyList())
|
||||
: Collections.emptyList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ public class AuthFacadeImpl implements AuthFacade {
|
||||
private final AuthNoticeProperties authNoticeProperties;
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class);
|
||||
|
||||
/**
|
||||
* 회원가입·아이디/비밀번호 찾기 등 로그인 이전 흐름은 수신자 이름을 알 수 없으므로
|
||||
* 메시지 템플릿 %USER_NAME% 자리에 넣을 기본값.
|
||||
*/
|
||||
private static final String GUEST_USER_NAME = "guest";
|
||||
|
||||
|
||||
/**
|
||||
* 인증 요청
|
||||
@@ -43,7 +49,7 @@ public class AuthFacadeImpl implements AuthFacade {
|
||||
}
|
||||
|
||||
try {
|
||||
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType);
|
||||
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType, GUEST_USER_NAME);
|
||||
response.setValid(true);
|
||||
response.setMessage("인증번호를 발송하였습니다.");
|
||||
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.djb.menu.PortalRolesProperties;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
@@ -50,7 +50,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserMapper portalUserMapper;
|
||||
private final PortalProperties portalProperties;
|
||||
private final PortalRolesProperties portalRolesProperties;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
@@ -76,7 +76,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
*/
|
||||
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
List<String> roles = portalRolesProperties.getAuthorities(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -35,7 +36,7 @@ public class UserRoleHistoryService {
|
||||
* 역할 변경 이력을 기록한다. 감사 목적이므로 실패해도 본 트랜잭션을 롤백시키지 않도록
|
||||
* 호출부에서 예외를 전파하지 않는다(내부에서 로깅만).
|
||||
*/
|
||||
@Transactional
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void record(String targetLoginId, RoleCode before, RoleCode after, ChangeType changeType) {
|
||||
try {
|
||||
String actor = SecurityUtil.getCurrentLoginId();
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSite;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
|
||||
@ControllerAdvice
|
||||
@@ -35,6 +37,9 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private AuthNoticeProperties authNoticeProperties;
|
||||
|
||||
@Autowired
|
||||
private RelatedSiteService relatedSiteService;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@@ -111,4 +116,21 @@ public class GlobalControllerAdvice {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("relatedSitesLabel")
|
||||
public String relatedSitesLabel() {
|
||||
return relatedSiteService.getLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 관련 사이트 목록. PortalProperty(Portal/footer.related-sites)의 '이름=URL' 줄 목록을 파싱한 결과.
|
||||
* 비어 있으면 푸터에서 셀렉트 자체를 렌더하지 않는다.
|
||||
*/
|
||||
@ModelAttribute("relatedSites")
|
||||
public List<RelatedSite> relatedSites() {
|
||||
return relatedSiteService.getSites();
|
||||
}
|
||||
}
|
||||
|
||||
+83
-21
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -20,6 +21,8 @@ import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
||||
@@ -29,15 +32,19 @@ import java.util.Map;
|
||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
||||
*
|
||||
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
|
||||
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
|
||||
* <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
|
||||
* 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
|
||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
|
||||
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||
* 기본은 dry-run(미변경)이며, 실제 실행은 {@code dryRun=false}를 명시해야 한다.
|
||||
* 작업 완료 후 이 클래스는 제거한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* # 미리보기(변경 안 함)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
||||
* # 실제 실행 (PII 컬럼)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
||||
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
|
||||
* # audit 컬럼(created_by/last_modified_by, 19개 테이블)까지 포함
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
||||
* </pre>
|
||||
*/
|
||||
@@ -46,13 +53,14 @@ import java.util.Map;
|
||||
@RequestMapping("/internal/migration")
|
||||
public class LegacyEncryptionMigrationController {
|
||||
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
|
||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
|
||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad", "ofctelno")),
|
||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
|
||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")),
|
||||
new TargetTable("PTL_USER_INVITATION", Arrays.asList("INVITATION_MOBILE"))
|
||||
);
|
||||
|
||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
||||
@@ -61,16 +69,24 @@ public class LegacyEncryptionMigrationController {
|
||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
|
||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application",
|
||||
"PTL_MENU_ITEM", "PTL_MENU_PLACEMENT", "PTL_ROLE", "PTL_ROLE_AUTHORITY"
|
||||
);
|
||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
||||
|
||||
static final String PROP_GROUP = "Portal";
|
||||
static final String PROP_ALLOW_IPS = "migration.internal.allow-ips";
|
||||
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
||||
|
||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
|
||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
|
||||
PortalPropertyService portalPropertyService) {
|
||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
||||
this.portalPropertyService = portalPropertyService;
|
||||
}
|
||||
|
||||
@PostMapping("/encrypt-legacy")
|
||||
@@ -78,7 +94,7 @@ public class LegacyEncryptionMigrationController {
|
||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||
assertLocalOnly(request);
|
||||
assertAllowedIp(request);
|
||||
assertNotBypass();
|
||||
|
||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||
@@ -90,24 +106,36 @@ public class LegacyEncryptionMigrationController {
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int totalChanged = 0;
|
||||
int totalSkipped = 0;
|
||||
for (TargetTable target : targets) {
|
||||
for (String column : target.columns) {
|
||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
||||
results.add(r);
|
||||
totalChanged += (int) r.get("changed");
|
||||
totalSkipped += (int) r.get("skipped");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
||||
response.put("damoMode", resolveDamoMode());
|
||||
response.put("includeAudit", includeAudit);
|
||||
response.put("totalChanged", totalChanged);
|
||||
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
|
||||
response.put("totalSkipped", totalSkipped);
|
||||
response.put("results", results);
|
||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
|
||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
|
||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
|
||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
|
||||
return response;
|
||||
}
|
||||
|
||||
private String resolveDamoMode() {
|
||||
if (converter.isBypassMode()) {
|
||||
return "BYPASS";
|
||||
}
|
||||
return converter.isFakeMode() ? "FAKE" : "REAL";
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
||||
*/
|
||||
@@ -125,11 +153,13 @@ public class LegacyEncryptionMigrationController {
|
||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
||||
r.put("distinct", 0);
|
||||
r.put("changed", 0);
|
||||
r.put("skipped", 0);
|
||||
r.put("error", e.getMessage());
|
||||
return r;
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
int skipped = 0;
|
||||
for (String value : values) {
|
||||
String normalized;
|
||||
try {
|
||||
@@ -139,6 +169,13 @@ public class LegacyEncryptionMigrationController {
|
||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
||||
continue;
|
||||
}
|
||||
if ((normalized == null || normalized.isEmpty()) && !value.isEmpty()) {
|
||||
// 방어: 원본이 비어있지 않은데 정규화 결과가 빈 값 → 절대 UPDATE 하지 않음 (데이터 소실 방지)
|
||||
skipped++;
|
||||
log.warn("[마이그레이션] 정규화 결과가 빈 값 — UPDATE 생략 table={} column={} valueLen={}",
|
||||
table, column, value.length());
|
||||
continue;
|
||||
}
|
||||
if (normalized != null && !normalized.equals(value)) {
|
||||
if (!dryRun) {
|
||||
jdbcTemplate.update(
|
||||
@@ -151,6 +188,7 @@ public class LegacyEncryptionMigrationController {
|
||||
|
||||
r.put("distinct", values.size());
|
||||
r.put("changed", changed);
|
||||
r.put("skipped", skipped);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -169,21 +207,45 @@ public class LegacyEncryptionMigrationController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||
* PTL_PROPERTY({@code Portal / migration.internal.allow-ips}) 허용 IP 목록 검사.
|
||||
* 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. ({@code MenuInternalController} 모델)
|
||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 기본값으로는 호출 불가 — 실행 전 property 에
|
||||
* 호출자 IP 를 추가하고 완료 후 원복한다.
|
||||
*/
|
||||
private void assertLocalOnly(HttpServletRequest request) {
|
||||
String remote = request.getRemoteAddr();
|
||||
boolean localAddr = "127.0.0.1".equals(remote)
|
||||
|| "0:0:0:0:0:0:0:1".equals(remote)
|
||||
|| "::1".equals(remote);
|
||||
private void assertAllowedIp(HttpServletRequest request) {
|
||||
String remote = canonicalize(request.getRemoteAddr());
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
if (!localAddr || viaProxy) {
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}",
|
||||
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
||||
|
||||
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
|
||||
.map(String::trim)
|
||||
.filter(ip -> !ip.isEmpty())
|
||||
.map(LegacyEncryptionMigrationController::canonicalize)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (viaProxy || !allowed.contains(remote)) {
|
||||
log.warn("[마이그레이션] 비허용 접근 차단 remoteAddr={} viaProxy={} xff={}",
|
||||
StringMaskingUtil.maskIpAddress(remote), viaProxy,
|
||||
StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"허용되지 않은 접근입니다. (PTL_PROPERTY " + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveAllowIps() {
|
||||
try {
|
||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
|
||||
DEFAULT_ALLOW_IPS, "레거시 암호화 마이그레이션 내부 API 허용 IP 목록(콤마 구분)");
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
||||
return DEFAULT_ALLOW_IPS;
|
||||
}
|
||||
}
|
||||
|
||||
/** IPv6 loopback 표기 통일 */
|
||||
private static String canonicalize(String ip) {
|
||||
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
|
||||
}
|
||||
|
||||
private static final class TargetTable {
|
||||
final String table;
|
||||
final List<String> columns;
|
||||
|
||||
@@ -109,6 +109,7 @@ public class PortalConfigSecurity {
|
||||
.csrfTokenRepository(csrfTokenRepository)
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
|
||||
)
|
||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||
|
||||
@@ -44,6 +44,7 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
private final Environment environment;
|
||||
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService;
|
||||
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
|
||||
private final com.eactive.apim.portal.djb.menu.MenuService menuService;
|
||||
|
||||
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
|
||||
@@ -58,10 +59,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
public PortalConfigWebDispatcherServlet(Environment environment,
|
||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties) {
|
||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
|
||||
com.eactive.apim.portal.djb.menu.MenuService menuService) {
|
||||
this.environment = environment;
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.twoFactorProperties = twoFactorProperties;
|
||||
this.menuService = menuService;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +134,14 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(staticExcludes)
|
||||
.excludePathPatterns("/auth/2fa/**");
|
||||
|
||||
// 메뉴 접근 권한(ACCESS_ROLES) 가드. 메뉴 경로 정확 일치 시에만 검사하며
|
||||
// 하위 경로는 기존 @Secured / PageRoute.role 안전망에 위임한다.
|
||||
registry.addInterceptor(new com.eactive.apim.portal.djb.menu.MenuAccessInterceptor(menuService))
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(staticExcludes)
|
||||
.excludePathPatterns("/internal/**", "/auth/2fa/**",
|
||||
"/login", "/actionLogin.do", "/actionLogout.do", "/error");
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.common.pagerouter.property.PageRoute;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@@ -25,8 +23,6 @@ public class PortalProperties {
|
||||
|
||||
private String authVirtualCode = "";
|
||||
|
||||
private Map<RoleCode, List<String>> portalSecurity;
|
||||
|
||||
private FileProperties file = new FileProperties();
|
||||
|
||||
/**
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.controller;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiCurrentStatusService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusIssueHistoryService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusQueryService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusSupport;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusUptimeService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.MyApiStatusQueryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 개발자포탈 API Status 화면.
|
||||
*
|
||||
* <p>장애/점검 데이터는 관리자 공지사항(PTL_NOTICE + DJB_APISTATUS_INCIDENT)과
|
||||
* 자동 탐지(eapim-admin ApiStatusDetectionService)가 채운다. 본 컨트롤러는 읽기 전용이다.</p>
|
||||
*
|
||||
* <p>비로그인 사용자도 모두 조회 가능하며, "내 API 현황"만 로그인을 요구한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/apistatus")
|
||||
@RequiredArgsConstructor
|
||||
public class ApiStatusController {
|
||||
|
||||
private static final int DEFAULT_RECENT_ISSUE_SIZE = 5;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
/** 현재 상태 일괄 조회 시 한 번에 물어볼 수 있는 API 수 */
|
||||
private static final int MAX_STATUS_BATCH = 100;
|
||||
|
||||
private final ApiStatusQueryService apiStatusQueryService;
|
||||
private final ApiStatusUptimeService uptimeService;
|
||||
private final ApiStatusIssueHistoryService issueHistoryService;
|
||||
private final MyApiStatusQueryService myApiStatusQueryService;
|
||||
private final ApiStatusCatalogService catalogService;
|
||||
private final ApiCurrentStatusService apiCurrentStatusService;
|
||||
|
||||
/** P1 - API Status 메인 */
|
||||
@GetMapping
|
||||
public ModelAndView index() {
|
||||
ModelAndView mav = new ModelAndView("djb/apistatus/index");
|
||||
mav.addObject("windowDays", catalogService.getWindowDays());
|
||||
mav.addObject("authenticated", SecurityUtil.isAuthenticated());
|
||||
LocalDateTime lastFireAt = catalogService.getLastMonitorFireAt();
|
||||
mav.addObject("lastFireAt", lastFireAt);
|
||||
mav.addObject("lastFireRelative",
|
||||
ApiStatusSupport.relativeTime(lastFireAt, ApiStatusSupport.now()));
|
||||
mav.addObject("lastStatusChangedAt", catalogService.getLastStatusChangedAt());
|
||||
return mav;
|
||||
}
|
||||
|
||||
/**
|
||||
* P8 - 전체 이슈 이력. 날짜를 지정하지 않으면 조회 기간(90일) 전체를 본다.
|
||||
* 날짜 선택 가능 범위는 서버 기준 일자로 내려 클라이언트 timezone 차이를 없앤다.
|
||||
*/
|
||||
@GetMapping("/issues")
|
||||
public ModelAndView issues(
|
||||
@RequestParam(value = "date", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam(value = "apiId", required = false) String apiId,
|
||||
@RequestParam(value = "kind", required = false) String kind) {
|
||||
|
||||
LocalDate today = ApiStatusSupport.now().toLocalDate();
|
||||
int windowDays = catalogService.getWindowDays();
|
||||
|
||||
ModelAndView mav = new ModelAndView("djb/apistatus/issues");
|
||||
mav.addObject("windowDays", windowDays);
|
||||
mav.addObject("today", today);
|
||||
mav.addObject("minDate", today.minusDays(windowDays - 1L));
|
||||
mav.addObject("selectedDate", date);
|
||||
mav.addObject("selectedApiId", apiId);
|
||||
mav.addObject("selectedKind", kind);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** 이슈 이력 API 필터 - 현재 사용자가 조회 가능한 API 목록 */
|
||||
@GetMapping("/apis.json")
|
||||
@ResponseBody
|
||||
public List<ApiOptionDTO> selectableApis() {
|
||||
return catalogService.getSelectableApis();
|
||||
}
|
||||
|
||||
/** P2 - 90일 가동률 */
|
||||
@GetMapping("/uptime.json")
|
||||
@ResponseBody
|
||||
public List<DailyStatDTO> uptime(
|
||||
@RequestParam(value = "days", defaultValue = "0") int days) {
|
||||
return uptimeService.getDailyStats(days > 0 ? days : catalogService.getWindowDays());
|
||||
}
|
||||
|
||||
/** P3 - 진행 중 장애 */
|
||||
@GetMapping("/active.json")
|
||||
@ResponseBody
|
||||
public List<ActiveIncidentDTO> active() {
|
||||
return apiStatusQueryService.getActiveIncidents();
|
||||
}
|
||||
|
||||
/** P4 - 내 API 현황 (로그인 필수) */
|
||||
@GetMapping("/my-apis.json")
|
||||
@ResponseBody
|
||||
public ResponseEntity<List<MyApiStatusDTO>> myApis() {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return new ResponseEntity<>(Collections.emptyList(), HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
return ResponseEntity.ok(myApiStatusQueryService.getMyApiStatuses());
|
||||
}
|
||||
|
||||
/** P5 - 예정/진행 중 점검 */
|
||||
@GetMapping("/maintenance.json")
|
||||
@ResponseBody
|
||||
public List<MaintenanceCardDTO> maintenance() {
|
||||
return apiStatusQueryService.getOngoingMaintenance();
|
||||
}
|
||||
|
||||
/** P6 - 지난 이슈 (종결) */
|
||||
@GetMapping("/recent-issues.json")
|
||||
@ResponseBody
|
||||
public List<PastIssueCardDTO> recentIssues(
|
||||
@RequestParam(value = "size", defaultValue = "" + DEFAULT_RECENT_ISSUE_SIZE) int size) {
|
||||
return apiStatusQueryService.getRecentClosedIssues(size);
|
||||
}
|
||||
|
||||
/** P7 - 이슈 상세 */
|
||||
@GetMapping("/incident/{incidentId}")
|
||||
@ResponseBody
|
||||
public ResponseEntity<PastIssueCardDTO> incidentDetail(@PathVariable Long incidentId) {
|
||||
return apiStatusQueryService.getIssueDetail(incidentId)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/** P9 - 90일 이슈 일자 인덱스 */
|
||||
@GetMapping("/issues/dates.json")
|
||||
@ResponseBody
|
||||
public List<IssueDateEntryDTO> issueDates(
|
||||
@RequestParam(value = "days", defaultValue = "0") int days,
|
||||
@RequestParam(value = "apiId", required = false) String apiId,
|
||||
@RequestParam(value = "kind", required = false) String kind) {
|
||||
return issueHistoryService.getIssueDates(days > 0 ? days : catalogService.getWindowDays(), apiId, kind);
|
||||
}
|
||||
|
||||
/** P10 - 이슈 목록 (날짜/API/유형 필터) */
|
||||
@GetMapping("/issues/list.json")
|
||||
@ResponseBody
|
||||
public Page<PastIssueCardDTO> issueList(
|
||||
@RequestParam(value = "date", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam(value = "apiId", required = false) String apiId,
|
||||
@RequestParam(value = "kind", required = false) String kind,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "20") int size) {
|
||||
|
||||
int safePage = Math.max(page, 0);
|
||||
int safeSize = size <= 0 ? 20 : Math.min(size, MAX_PAGE_SIZE);
|
||||
return issueHistoryService.getIssues(date, apiId, kind, PageRequest.of(safePage, safeSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* P11 - API 한 건의 현재 상태 (정상/점검/지연/장애). API 상세 화면이 사용한다.
|
||||
*
|
||||
* <p>게시된 장애·점검만 반영하므로 비로그인도 조회 가능하다.</p>
|
||||
*/
|
||||
@GetMapping("/current.json")
|
||||
@ResponseBody
|
||||
public ResponseEntity<ApiCurrentStatusDTO> currentStatus(@RequestParam("apiId") String apiId) {
|
||||
return apiCurrentStatusService.getStatus(apiId)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.badRequest().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* P11 - 여러 API 의 현재 상태. {@code ?apiId=A&apiId=B} 로 반복 지정한다.
|
||||
*/
|
||||
@GetMapping("/current-list.json")
|
||||
@ResponseBody
|
||||
public ResponseEntity<List<ApiCurrentStatusDTO>> currentStatuses(
|
||||
@RequestParam(value = "apiId", required = false) List<String> apiIds) {
|
||||
|
||||
if (apiIds != null && apiIds.size() > MAX_STATUS_BATCH) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
return ResponseEntity.ok(apiCurrentStatusService.getStatuses(apiIds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 진행 중 장애 카드 (P3)
|
||||
*/
|
||||
@Data
|
||||
public class ActiveIncidentDTO {
|
||||
|
||||
private Long incidentId;
|
||||
private String noticeId;
|
||||
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||
private String noticeSubject;
|
||||
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||
private String noticeDetail;
|
||||
private String kind;
|
||||
private String state;
|
||||
private String stateLabel;
|
||||
private String title;
|
||||
private String summary;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime startedAt;
|
||||
private long elapsedMinutes;
|
||||
private List<AffectedApiDTO> apis = new ArrayList<>();
|
||||
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||
private int hiddenApiCount;
|
||||
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 이슈 영향 API
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AffectedApiDTO {
|
||||
|
||||
private String apiId;
|
||||
private String apiName;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime recoveredAt;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* API 한 건의 현재 상태 (정상/점검/지연/장애).
|
||||
*
|
||||
* <p>게시된 장애·점검 공지(PTL_NOTICE + DJB_APISTATUS_INCIDENT)만 근거로 한다.
|
||||
* 미게시(초안/USE_YN='N') 건은 다른 API Status 화면과 동일하게 보이지 않는다.</p>
|
||||
*/
|
||||
@Data
|
||||
public class ApiCurrentStatusDTO {
|
||||
|
||||
private String apiId;
|
||||
|
||||
/** 공개 API 목록(PTL_API_SPEC_INFO, DISPLAY_YN='Y')에 없으면 null */
|
||||
private String apiName;
|
||||
|
||||
/** NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
|
||||
private String currentStatus;
|
||||
|
||||
/** 정상 / 지연 / 장애 / 점검 */
|
||||
private String currentStatusLabel;
|
||||
|
||||
/** 현재 상태의 근거가 된 이슈. 정상이면 null */
|
||||
private Long activeIncidentId;
|
||||
|
||||
/** INCIDENT / MAINTENANCE */
|
||||
private String activeIncidentKind;
|
||||
|
||||
private String activeIncidentTitle;
|
||||
|
||||
/** 현재 상태가 시작된 시각 (근거 이슈의 시작 시각) */
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime statusSince;
|
||||
|
||||
/** 점검 종료 예정 시각. 장애이거나 미정이면 null */
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime expectedEndAt;
|
||||
|
||||
/** 조회 기간 내 마지막 장애 발생 시각 */
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime lastIncidentAt;
|
||||
|
||||
/** 조회 기간 가동률 (0.0 ~ 1.0). 상태만 계산하는 경로에서는 채우지 않는다 */
|
||||
private Double uptimeRatio;
|
||||
|
||||
/** 가동률·최근 장애 집계 기간(일) */
|
||||
private int windowDays;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 이슈 이력 API 필터 옵션. 화면에는 API 명만 노출하고 apiId 는 조회 파라미터로만 쓴다.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiOptionDTO {
|
||||
|
||||
private String apiId;
|
||||
private String apiName;
|
||||
|
||||
/** 소속 API 그룹명 (오픈 API 목록과 동일 기준) */
|
||||
private String groupName;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 90일 가동률 일별 집계 (P2)
|
||||
*/
|
||||
@Data
|
||||
public class DailyStatDTO {
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
||||
private LocalDate statDate;
|
||||
|
||||
/** 0.0000 ~ 1.0000 */
|
||||
private double uptimeRatio;
|
||||
|
||||
/** 장애 + 지연 합계 (가동률 차감분). 지연도 서비스 저하이므로 계속 차감한다 */
|
||||
private long incidentMinutes;
|
||||
|
||||
/** 그 중 지연(자동 탐지) 분 */
|
||||
private long delayMinutes;
|
||||
|
||||
private long maintenanceMinutes;
|
||||
|
||||
/** 막대 색상 구분: NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
|
||||
private String status;
|
||||
|
||||
private List<IssueRefDTO> issues = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class IssueRefDTO {
|
||||
private Long incidentId;
|
||||
private String kind;
|
||||
private String title;
|
||||
|
||||
public IssueRefDTO() {
|
||||
}
|
||||
|
||||
public IssueRefDTO(Long incidentId, String kind, String title) {
|
||||
this.incidentId = incidentId;
|
||||
this.kind = kind;
|
||||
this.title = title;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 이슈 이력 페이지의 90일 인덱스바 1칸 (P9)
|
||||
*/
|
||||
@Data
|
||||
public class IssueDateEntryDTO {
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
||||
private LocalDate date;
|
||||
|
||||
/** INCIDENT / DELAY / MAINTENANCE - 유형 필터와 같은 값 */
|
||||
private List<String> kinds = new ArrayList<>();
|
||||
|
||||
/** 장애 건수 (자동 탐지 지연 제외) */
|
||||
private int incCount;
|
||||
|
||||
/** 지연 건수 (자동 탐지 지연 - INCIDENT 중 INTERFACE_ID 가 DELAY_START: 인 건) */
|
||||
private int dlyCount;
|
||||
|
||||
private int mntCount;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 점검 사항 카드 (P5). 상태 관리를 하지 않으므로(ADR-F15) 진행 단계는 화면이 현재 시각과 비교해 부여한다.
|
||||
*/
|
||||
@Data
|
||||
public class MaintenanceCardDTO {
|
||||
|
||||
private Long incidentId;
|
||||
private String noticeId;
|
||||
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||
private String noticeSubject;
|
||||
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||
private String noticeDetail;
|
||||
private String title;
|
||||
private String summary;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime startedAt;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime endAt;
|
||||
private Long durationMinutes;
|
||||
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
||||
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||
private int hiddenApiCount;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime registeredAt;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime lastModifiedAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 내가 이용 중인 API 의 현재 상태 (P4)
|
||||
*/
|
||||
@Data
|
||||
public class MyApiStatusDTO {
|
||||
|
||||
private String apiId;
|
||||
private String apiName;
|
||||
|
||||
/** NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
|
||||
private String currentStatus;
|
||||
|
||||
private String currentStatusLabel;
|
||||
private Long activeIncidentId;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime lastIncidentAt;
|
||||
private double uptime90d;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 지난 이슈 카드 (P6, P10). 장애는 타임라인 전체, 점검은 요약만 담는다.
|
||||
*/
|
||||
@Data
|
||||
public class PastIssueCardDTO {
|
||||
|
||||
private Long incidentId;
|
||||
private String noticeId;
|
||||
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||
private String noticeSubject;
|
||||
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||
private String noticeDetail;
|
||||
private String kind;
|
||||
private String state;
|
||||
private String stateLabel;
|
||||
private String title;
|
||||
private String summary;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime startedAt;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime endAt;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
||||
private LocalDate dateGroup;
|
||||
private Long durationMinutes;
|
||||
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
||||
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||
private int hiddenApiCount;
|
||||
private List<TimelineEntryDTO> timeline = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 장애 처리 타임라인 1건
|
||||
*/
|
||||
@Data
|
||||
public class TimelineEntryDTO {
|
||||
|
||||
private Long timelineId;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime eventAt;
|
||||
private String stateAfter;
|
||||
private String labelKo;
|
||||
private String body;
|
||||
private String authorType;
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.Repository;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건을 항상 적용한다.
|
||||
*
|
||||
* <p>공개 조건({@link #VISIBLE})은 두 갈래다.
|
||||
* <ul>
|
||||
* <li>공지가 붙은 이슈(장애·점검) - 그 공지가 게시(USE_YN='Y')되어 있어야 한다.
|
||||
* 공지가 삭제된 고아 행도 이 조건에서 자연히 빠진다 (물리 FK 없음 - ADR-F10).</li>
|
||||
* <li>공지가 없는 이슈(지연) - 자동 탐지 전용이라 검수할 공지가 없다.
|
||||
* 초안(DRAFT_YN='Y')만 아니면 노출한다.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
|
||||
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
|
||||
*/
|
||||
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
|
||||
|
||||
String VISIBLE = " i.draftYn = 'N'"
|
||||
+ " AND (i.noticeId IS NULL"
|
||||
+ " OR EXISTS (SELECT 1 FROM PortalNotice n"
|
||||
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y')) ";
|
||||
|
||||
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
|
||||
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
|
||||
String KIND_DELAY = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.DELAY";
|
||||
String KIND_MAINTENANCE = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE";
|
||||
|
||||
/** 종결 조건 - 장애·지연은 STATE 로, 점검은 종료 시각 경과로 판정한다 */
|
||||
String CLOSED_CONDITION = " ((i.kind IN (" + KIND_INCIDENT + ", " + KIND_DELAY + ")"
|
||||
+ " AND i.state IN :closedStates)"
|
||||
+ " OR (i.kind = " + KIND_MAINTENANCE
|
||||
+ " AND i.endAt IS NOT NULL AND i.endAt < :now)) ";
|
||||
|
||||
/**
|
||||
* 진행 중 이슈 (P3). 장애와 지연을 함께 본다.
|
||||
*/
|
||||
@Query("SELECT i FROM DjbApistatusIncident i"
|
||||
+ " WHERE " + VISIBLE
|
||||
+ " AND i.kind IN :kinds"
|
||||
+ " AND i.state NOT IN :closedStates"
|
||||
+ " ORDER BY i.startedAt DESC")
|
||||
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
|
||||
@Param("closedStates") Collection<IncidentState> closedStates);
|
||||
|
||||
/**
|
||||
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
|
||||
*/
|
||||
@Query("SELECT i FROM DjbApistatusIncident i"
|
||||
+ " WHERE " + VISIBLE
|
||||
+ " AND i.kind = :kind"
|
||||
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
|
||||
+ " ORDER BY i.startedAt ASC")
|
||||
List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind,
|
||||
@Param("now") LocalDateTime now);
|
||||
|
||||
/**
|
||||
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
|
||||
*/
|
||||
@Query(value = "SELECT i FROM DjbApistatusIncident i"
|
||||
+ " WHERE " + VISIBLE
|
||||
+ " AND " + CLOSED_CONDITION
|
||||
+ " ORDER BY i.startedAt DESC",
|
||||
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i"
|
||||
+ " WHERE " + VISIBLE
|
||||
+ " AND " + CLOSED_CONDITION)
|
||||
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
|
||||
@Param("now") LocalDateTime now,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 기간과 겹치는 모든 이슈 (P2 가동률 집계, P9 일자 인덱스, P10 목록).
|
||||
* 진행 중(END_AT IS NULL) 이슈도 포함한다.
|
||||
*/
|
||||
@Query("SELECT i FROM DjbApistatusIncident i"
|
||||
+ " WHERE " + VISIBLE
|
||||
+ " AND i.startedAt < :to"
|
||||
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
|
||||
+ " ORDER BY i.startedAt DESC")
|
||||
List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from,
|
||||
@Param("to") LocalDateTime to);
|
||||
|
||||
/**
|
||||
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
|
||||
*/
|
||||
@Query("SELECT DISTINCT i FROM DjbApistatusIncident i, DjbApistatusIncidentApi a"
|
||||
+ " WHERE " + VISIBLE
|
||||
+ " AND a.incidentId = i.incidentId"
|
||||
+ " AND a.apiId = :apiId"
|
||||
+ " AND i.startedAt < :to"
|
||||
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
|
||||
+ " ORDER BY i.startedAt DESC")
|
||||
List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from,
|
||||
@Param("to") LocalDateTime to,
|
||||
@Param("apiId") String apiId);
|
||||
|
||||
/**
|
||||
* 공개 상세 (P7)
|
||||
*/
|
||||
@Query("SELECT i FROM DjbApistatusIncident i"
|
||||
+ " WHERE " + VISIBLE
|
||||
+ " AND i.incidentId = :incidentId")
|
||||
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId);
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.service;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
|
||||
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.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.repository.ApiStatusIncidentQueryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* API 별 현재 상태(정상/점검/지연/장애) 조회.
|
||||
*
|
||||
* <p>"내 API 현황"(P4)과 API 상세 화면이 이 서비스를 공유해 판정 기준을 하나로 유지한다.
|
||||
* 근거는 <b>게시된</b> 장애·점검 공지뿐이며(PTL_NOTICE.USE_YN='Y' + DRAFT_YN='N'),
|
||||
* 미게시 건은 다른 API Status 화면과 마찬가지로 반영하지 않는다.</p>
|
||||
*
|
||||
* <p>판정 규칙 (심각한 쪽 우선)
|
||||
* <ul>
|
||||
* <li>진행 중 장애 → 장애(OUTAGE). 단 모니터링 상태이거나 자동 탐지 지연 건이면 지연(DEGRADED)</li>
|
||||
* <li>이미 시작된 점검 → 점검(MAINTENANCE)</li>
|
||||
* <li>해당 없음 → 정상(NORMAL)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ApiCurrentStatusService {
|
||||
|
||||
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||
private final ApiSpecInfoRepository apiSpecInfoRepository;
|
||||
private final ApiStatusUptimeService uptimeService;
|
||||
private final ApiStatusCatalogService catalogService;
|
||||
|
||||
/** API 한 건의 현재 상태. apiId 가 비어 있으면 empty */
|
||||
public Optional<ApiCurrentStatusDTO> getStatus(String apiId) {
|
||||
if (StringUtils.isBlank(apiId)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return getStatuses(Collections.singletonList(apiId)).stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 API 의 현재 상태 (API 명·가동률 포함). 요청 순서를 유지하며 중복 apiId 는 한 번만 반환한다.
|
||||
*/
|
||||
public List<ApiCurrentStatusDTO> getStatuses(Collection<String> apiIds) {
|
||||
Set<String> ids = normalize(apiIds);
|
||||
if (ids.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<String, ApiCurrentStatusDTO> statuses = resolveStatuses(ids);
|
||||
Map<String, String> names = resolveApiNames(ids);
|
||||
Map<String, Double> uptimes = uptimeService.getApiUptimeRatios(ids, catalogService.getWindowDays());
|
||||
|
||||
List<ApiCurrentStatusDTO> result = new ArrayList<>(ids.size());
|
||||
for (String apiId : ids) {
|
||||
ApiCurrentStatusDTO dto = statuses.get(apiId);
|
||||
dto.setApiName(names.get(apiId));
|
||||
dto.setUptimeRatio(uptimes.getOrDefault(apiId, 1.0d));
|
||||
result.add(dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태만 계산한다 (API 명·가동률 제외). 대상 API 를 이미 알고 있고 이름·가동률을
|
||||
* 따로 채우는 호출자(P4 내 API 현황)를 위한 경량 경로다.
|
||||
*/
|
||||
public Map<String, ApiCurrentStatusDTO> resolveStatuses(Set<String> apiIds) {
|
||||
if (apiIds == null || apiIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
LocalDateTime now = ApiStatusSupport.now();
|
||||
int windowDays = catalogService.getWindowDays();
|
||||
|
||||
Map<String, DjbApistatusIncident> openByApi = mapOpenIncidents(apiIds);
|
||||
Map<String, DjbApistatusIncident> maintenanceByApi = mapStartedMaintenance(apiIds, now);
|
||||
Map<String, LocalDateTime> lastIncidentAt = collectLastIncidentAt(apiIds, now, windowDays);
|
||||
|
||||
Map<String, ApiCurrentStatusDTO> result = new LinkedHashMap<>();
|
||||
for (String apiId : apiIds) {
|
||||
DjbApistatusIncident open = openByApi.get(apiId);
|
||||
DjbApistatusIncident maintenance = maintenanceByApi.get(apiId);
|
||||
|
||||
ApiCurrentStatusDTO dto = new ApiCurrentStatusDTO();
|
||||
dto.setApiId(apiId);
|
||||
dto.setCurrentStatus(resolveStatus(open, maintenance != null));
|
||||
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
|
||||
dto.setLastIncidentAt(lastIncidentAt.get(apiId));
|
||||
dto.setWindowDays(windowDays);
|
||||
|
||||
// 장애가 점검보다 심각하므로 근거 이슈도 장애를 우선한다
|
||||
DjbApistatusIncident active = open != null ? open : maintenance;
|
||||
if (active != null) {
|
||||
dto.setActiveIncidentId(active.getIncidentId());
|
||||
dto.setActiveIncidentKind(active.getKind() == null ? null : active.getKind().name());
|
||||
dto.setActiveIncidentTitle(active.getTitle());
|
||||
dto.setStatusSince(active.getStartedAt());
|
||||
dto.setExpectedEndAt(active.getEndAt());
|
||||
}
|
||||
result.put(apiId, dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 빈 값·중복 제거. 요청 순서는 유지한다 */
|
||||
private Set<String> normalize(Collection<String> apiIds) {
|
||||
if (apiIds == null || apiIds.isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return apiIds.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.map(StringUtils::trim)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* API 명은 공개 목록(DISPLAY_YN='Y')에서만 가져온다. 비공개 API 의 이름이 새어나가지 않게 한다.
|
||||
*/
|
||||
private Map<String, String> resolveApiNames(Set<String> apiIds) {
|
||||
List<ApiSpecInfo> specs =
|
||||
apiSpecInfoRepository.findAllByDisplayYnAndApiIdIn("Y", new ArrayList<>(apiIds));
|
||||
Map<String, String> names = new HashMap<>();
|
||||
for (ApiSpecInfo spec : specs) {
|
||||
names.put(spec.getApiId(), StringUtils.defaultIfBlank(spec.getApiName(), spec.getApiId()));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
|
||||
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
|
||||
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
|
||||
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
|
||||
if (openIncidents.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, DjbApistatusIncident> byId = openIncidents.stream()
|
||||
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
|
||||
|
||||
Map<String, DjbApistatusIncident> byApi = new HashMap<>();
|
||||
for (DjbApistatusIncidentApi api :
|
||||
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
|
||||
if (!apiIds.contains(api.getApiId()) || api.getRecoveredAt() != null) {
|
||||
continue;
|
||||
}
|
||||
DjbApistatusIncident incident = byId.get(api.getIncidentId());
|
||||
DjbApistatusIncident previous = byApi.get(api.getApiId());
|
||||
// 같은 API 에 여러 장애가 열려 있으면 더 심각한(장애 > 지연, 조사중 > 모니터링) 쪽을 우선한다
|
||||
if (previous == null || severity(incident) < severity(previous)) {
|
||||
byApi.put(api.getApiId(), incident);
|
||||
}
|
||||
}
|
||||
return byApi;
|
||||
}
|
||||
|
||||
/** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
|
||||
private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) {
|
||||
List<DjbApistatusIncident> maintenances = incidentQueryRepository
|
||||
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream()
|
||||
.filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
|
||||
.collect(Collectors.toList());
|
||||
if (maintenances.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, DjbApistatusIncident> byId = maintenances.stream()
|
||||
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
|
||||
|
||||
Map<String, DjbApistatusIncident> byApi = new HashMap<>();
|
||||
for (DjbApistatusIncidentApi api :
|
||||
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
|
||||
if (!apiIds.contains(api.getApiId())) {
|
||||
continue;
|
||||
}
|
||||
DjbApistatusIncident incident = byId.get(api.getIncidentId());
|
||||
DjbApistatusIncident previous = byApi.get(api.getApiId());
|
||||
if (previous == null
|
||||
|| (incident.getStartedAt() != null && previous.getStartedAt() != null
|
||||
&& incident.getStartedAt().isBefore(previous.getStartedAt()))) {
|
||||
byApi.put(api.getApiId(), incident);
|
||||
}
|
||||
}
|
||||
return byApi;
|
||||
}
|
||||
|
||||
/** 조회 기간 내 마지막 장애·지연 발생 시각 */
|
||||
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) {
|
||||
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
|
||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
|
||||
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
||||
.collect(Collectors.toList());
|
||||
if (incidents.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, DjbApistatusIncident> byId = incidents.stream()
|
||||
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
|
||||
|
||||
Map<String, LocalDateTime> result = new HashMap<>();
|
||||
for (DjbApistatusIncidentApi api :
|
||||
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
|
||||
if (!apiIds.contains(api.getApiId())) {
|
||||
continue;
|
||||
}
|
||||
LocalDateTime startedAt = byId.get(api.getIncidentId()).getStartedAt();
|
||||
LocalDateTime previous = result.get(api.getApiId());
|
||||
if (startedAt != null && (previous == null || startedAt.isAfter(previous))) {
|
||||
result.put(api.getApiId(), startedAt);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 중 장애·지연 + 점검 여부로 현재 상태를 정한다.
|
||||
*
|
||||
* <p>KIND 가 DELAY 면 지연이다. 장애(INCIDENT)라도 모니터링 단계면 이미 완화된 상태라
|
||||
* 지연으로 표기한다 - 이슈 이력의 유형 필터(KIND 기준)와는 이 지점만 다르다.</p>
|
||||
*/
|
||||
private String resolveStatus(DjbApistatusIncident open, boolean underMaintenance) {
|
||||
if (open != null) {
|
||||
boolean degraded = open.getKind() == IncidentKind.DELAY
|
||||
|| open.getState() == IncidentState.MONITORING;
|
||||
return degraded ? ApiStatusSupport.STATUS_DEGRADED : ApiStatusSupport.STATUS_OUTAGE;
|
||||
}
|
||||
if (underMaintenance) {
|
||||
return ApiStatusSupport.STATUS_MAINTENANCE;
|
||||
}
|
||||
return ApiStatusSupport.STATUS_NORMAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 API 에 열린 이슈가 여럿일 때의 우선순위 (낮을수록 심각).
|
||||
* 지연은 장애보다 뒤로 민다.
|
||||
*/
|
||||
private int severity(DjbApistatusIncident incident) {
|
||||
if (incident == null) {
|
||||
return 99;
|
||||
}
|
||||
int base = incident.getKind() == IncidentKind.DELAY ? 10 : 0;
|
||||
IncidentState state = incident.getState();
|
||||
if (state == null) {
|
||||
return base + 9;
|
||||
}
|
||||
switch (state) {
|
||||
case INVESTIGATING: return base;
|
||||
case IDENTIFIED: return base + 1;
|
||||
// 모니터링 단계는 지연으로 표기되므로 같은 장애라도 뒤로 민다
|
||||
case MONITORING: return base + 12;
|
||||
default: return base + 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.AffectedApiDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
|
||||
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.repository.DjbApistatusIncidentApiRepository;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
|
||||
import com.eactive.apim.portal.apps.community.notice.repository.PortalNoticeRepository;
|
||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 장애/점검 엔티티 → 화면 DTO 변환. 영향 API·타임라인은 한 번에 모아 읽어 1+N 조회를 피한다.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ApiStatusAssembler {
|
||||
|
||||
private static final int RECENT_TIMELINE_SIZE = 3;
|
||||
|
||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||
private final DjbApistatusIncidentTimelineRepository timelineRepository;
|
||||
private final PortalNoticeRepository portalNoticeRepository;
|
||||
private final ApiStatusCatalogService catalogService;
|
||||
|
||||
/**
|
||||
* 영향 인터페이스를 "개발자포탈에 게시된 API" 와 그 외 GW 인터페이스로 가른 결과.
|
||||
* 게시된 것만 개별 노출하고 나머지는 건수로만 알린다.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public static class VisibleApis {
|
||||
private final List<AffectedApiDTO> visible;
|
||||
private final int hiddenCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이슈 영향 인터페이스에서 현재 사용자에게 공개된 API 만 남긴다.
|
||||
*
|
||||
* <p>이름은 이슈에 캐시된 EAI 서비스명이 아니라 포털 노출명(PTL_API_SPEC_INFO)을 쓴다 -
|
||||
* 같은 API 가 화면마다 다른 이름으로 보이지 않게 한다.</p>
|
||||
*/
|
||||
private VisibleApis splitByVisibility(List<AffectedApiDTO> apis, Map<String, String> visibleNames) {
|
||||
if (apis == null || apis.isEmpty()) {
|
||||
return new VisibleApis(Collections.emptyList(), 0);
|
||||
}
|
||||
List<AffectedApiDTO> visible = new ArrayList<>();
|
||||
int hidden = 0;
|
||||
for (AffectedApiDTO api : apis) {
|
||||
String publishedName = visibleNames.get(api.getApiId());
|
||||
if (publishedName == null) {
|
||||
hidden++;
|
||||
continue;
|
||||
}
|
||||
visible.add(new AffectedApiDTO(api.getApiId(), publishedName, api.getRecoveredAt()));
|
||||
}
|
||||
return new VisibleApis(visible, hidden);
|
||||
}
|
||||
|
||||
public Map<Long, List<AffectedApiDTO>> loadApis(Collection<Long> incidentIds) {
|
||||
if (incidentIds == null || incidentIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, List<AffectedApiDTO>> result = new HashMap<>();
|
||||
for (DjbApistatusIncidentApi api :
|
||||
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(incidentIds)) {
|
||||
result.computeIfAbsent(api.getIncidentId(), key -> new ArrayList<>())
|
||||
.add(new AffectedApiDTO(api.getApiId(),
|
||||
StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()),
|
||||
api.getRecoveredAt()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 타임라인. 최신순으로 담는다.
|
||||
*/
|
||||
public Map<Long, List<TimelineEntryDTO>> loadTimelines(Collection<Long> incidentIds) {
|
||||
if (incidentIds == null || incidentIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, List<TimelineEntryDTO>> result = new HashMap<>();
|
||||
for (DjbApistatusIncidentTimeline timeline :
|
||||
timelineRepository.findByIncidentIdInAndVisibleYnOrderByEventAtDesc(incidentIds, "Y")) {
|
||||
result.computeIfAbsent(timeline.getIncidentId(), key -> new ArrayList<>())
|
||||
.add(toTimelineEntry(timeline));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이슈에 연결된 공지. 게시 중(USE_YN='Y')인 것만 담는다.
|
||||
*
|
||||
* <p>조회 자체의 공개 조건({@code ApiStatusIncidentQueryRepository.VISIBLE})과 같은 기준이라
|
||||
* 여기서 빠지는 건은 공지가 삭제된 고아 행뿐이다.</p>
|
||||
*/
|
||||
public Map<String, PortalNotice> loadNotices(Collection<String> noticeIds) {
|
||||
if (noticeIds == null || noticeIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<String> ids = noticeIds.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (ids.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<String, PortalNotice> result = new HashMap<>();
|
||||
for (PortalNotice notice : portalNoticeRepository.findByIdInAndUseYn(ids, "Y")) {
|
||||
result.put(notice.getId(), notice);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<ActiveIncidentDTO> toActiveIncidents(List<DjbApistatusIncident> incidents, LocalDateTime now) {
|
||||
if (incidents.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Long> ids = incidentIds(incidents);
|
||||
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
||||
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(ids);
|
||||
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
|
||||
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
|
||||
.map(DjbApistatusIncident::getNoticeId)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
List<ActiveIncidentDTO> result = new ArrayList<>();
|
||||
for (DjbApistatusIncident incident : incidents) {
|
||||
ActiveIncidentDTO dto = new ActiveIncidentDTO();
|
||||
dto.setIncidentId(incident.getIncidentId());
|
||||
dto.setNoticeId(incident.getNoticeId());
|
||||
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
|
||||
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
|
||||
dto.setTitle(incident.getTitle());
|
||||
dto.setSummary(incident.getSummary());
|
||||
dto.setStartedAt(incident.getStartedAt());
|
||||
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
|
||||
VisibleApis affected = splitByVisibility(
|
||||
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
|
||||
dto.setApis(affected.getVisible());
|
||||
dto.setHiddenApiCount(affected.getHiddenCount());
|
||||
|
||||
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
|
||||
if (notice != null) {
|
||||
dto.setNoticeSubject(notice.getNoticeSubject());
|
||||
dto.setNoticeDetail(notice.getNoticeDetail());
|
||||
}
|
||||
|
||||
List<TimelineEntryDTO> all = timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList());
|
||||
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
|
||||
? new ArrayList<>(all.subList(0, RECENT_TIMELINE_SIZE)) : all);
|
||||
result.add(dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<MaintenanceCardDTO> toMaintenanceCards(List<DjbApistatusIncident> incidents) {
|
||||
if (incidents.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Map<Long, List<AffectedApiDTO>> apis = loadApis(incidentIds(incidents));
|
||||
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
|
||||
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
|
||||
.map(DjbApistatusIncident::getNoticeId)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
List<MaintenanceCardDTO> result = new ArrayList<>();
|
||||
for (DjbApistatusIncident incident : incidents) {
|
||||
MaintenanceCardDTO dto = new MaintenanceCardDTO();
|
||||
dto.setIncidentId(incident.getIncidentId());
|
||||
dto.setNoticeId(incident.getNoticeId());
|
||||
|
||||
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
|
||||
if (notice != null) {
|
||||
dto.setNoticeSubject(notice.getNoticeSubject());
|
||||
dto.setNoticeDetail(notice.getNoticeDetail());
|
||||
}
|
||||
|
||||
dto.setTitle(incident.getTitle());
|
||||
dto.setSummary(incident.getSummary());
|
||||
dto.setStartedAt(incident.getStartedAt());
|
||||
dto.setEndAt(incident.getEndAt());
|
||||
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
||||
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
||||
VisibleApis affected = splitByVisibility(
|
||||
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
|
||||
dto.setImpactedApis(affected.getVisible());
|
||||
dto.setHiddenApiCount(affected.getHiddenCount());
|
||||
dto.setRegisteredAt(incident.getCreatedDate());
|
||||
dto.setLastModifiedAt(incident.getLastModifiedDate());
|
||||
result.add(dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지난 이슈 카드. 장애·지연은 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
|
||||
*/
|
||||
public List<PastIssueCardDTO> toPastIssueCards(List<DjbApistatusIncident> incidents) {
|
||||
if (incidents.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Long> ids = incidentIds(incidents);
|
||||
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
||||
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
|
||||
|
||||
List<Long> incidentKindIds = incidents.stream()
|
||||
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
||||
.map(DjbApistatusIncident::getIncidentId)
|
||||
.collect(Collectors.toList());
|
||||
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(incidentKindIds);
|
||||
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
|
||||
.map(DjbApistatusIncident::getNoticeId)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
List<PastIssueCardDTO> result = new ArrayList<>();
|
||||
for (DjbApistatusIncident incident : incidents) {
|
||||
PastIssueCardDTO dto = new PastIssueCardDTO();
|
||||
dto.setIncidentId(incident.getIncidentId());
|
||||
dto.setNoticeId(incident.getNoticeId());
|
||||
|
||||
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
|
||||
if (notice != null) {
|
||||
dto.setNoticeSubject(notice.getNoticeSubject());
|
||||
dto.setNoticeDetail(notice.getNoticeDetail());
|
||||
}
|
||||
|
||||
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
|
||||
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
|
||||
dto.setTitle(incident.getTitle());
|
||||
dto.setSummary(incident.getSummary());
|
||||
dto.setStartedAt(incident.getStartedAt());
|
||||
dto.setEndAt(incident.getEndAt());
|
||||
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
|
||||
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
||||
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
||||
VisibleApis affected = splitByVisibility(
|
||||
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
|
||||
dto.setImpactedApis(affected.getVisible());
|
||||
dto.setHiddenApiCount(affected.getHiddenCount());
|
||||
dto.setTimeline(timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
||||
result.add(dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private TimelineEntryDTO toTimelineEntry(DjbApistatusIncidentTimeline timeline) {
|
||||
TimelineEntryDTO dto = new TimelineEntryDTO();
|
||||
dto.setTimelineId(timeline.getTimelineId());
|
||||
dto.setEventAt(timeline.getEventAt());
|
||||
dto.setStateAfter(timeline.getStateAfter() == null ? null : timeline.getStateAfter().name());
|
||||
dto.setLabelKo(ApiStatusSupport.stateLabel(timeline.getStateAfter()));
|
||||
dto.setBody(timeline.getBody());
|
||||
dto.setAuthorType(timeline.getAuthorType());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private List<Long> incidentIds(List<DjbApistatusIncident> incidents) {
|
||||
return incidents.stream().map(DjbApistatusIncident::getIncidentId).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.service;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.repository.GwApiStatusRepository;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* API Status 화면의 부가 조회 - 필터용 API 목록, 상태 모니터링 최근 실행 시각.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ApiStatusCatalogService {
|
||||
|
||||
public static final String PROPERTY_GROUP = "Portal";
|
||||
|
||||
/** API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME) */
|
||||
public static final String KEY_MONITOR_JOB_NAME = "djb.apistatus.monitor.job-name";
|
||||
|
||||
/** 실서버(DAPM) QRTZ_JOB_DETAILS 실사값 (2026-07-31) */
|
||||
private static final String DEFAULT_MONITOR_JOB_NAME = "ApiStatusMonitorJob";
|
||||
|
||||
/** 상태/이력 조회 기간(일). 가동률 바·이슈 인덱스·date picker 범위가 모두 이 값을 따른다 */
|
||||
public static final String KEY_WINDOW_DAYS = "djb.apistatus.window-days";
|
||||
|
||||
/** OPEN API 목록 카드의 현재 상태 태그 노출 여부 (true/false) */
|
||||
public static final String KEY_API_LIST_STATUS_BADGE = "djb.apistatus.api-list-status-badge";
|
||||
|
||||
private static final boolean DEFAULT_API_LIST_STATUS_BADGE = true;
|
||||
|
||||
private static final int MIN_WINDOW_DAYS = 1;
|
||||
private static final int MAX_WINDOW_DAYS = 365;
|
||||
|
||||
private final ApiSearchFacade apiSearchFacade;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final GwApiStatusRepository gwApiStatusRepository;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
/**
|
||||
* 이슈 이력 필터용 API 목록.
|
||||
*
|
||||
* <p>"오픈 API" 목록 화면과 <b>같은 조회 경로</b>({@link ApiSearchFacade#searchApis})를 쓴다.
|
||||
* 즉 API 그룹(AGWAPP.API_GROUP + API_GROUP_API)에 편성된 API 중
|
||||
* PTL_API_SPEC_INFO 에 스펙이 있고 현재 사용자에게 공개된 것만 나온다.</p>
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<ApiOptionDTO> getSelectableApis() {
|
||||
Object apis = apiSearchFacade.searchApis(new ApiGroupSearch()).get("apis");
|
||||
if (!(apis instanceof List)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return ((List<ApiSpecInfoDto>) apis).stream()
|
||||
.filter(api -> StringUtils.isNotBlank(api.getApiId()))
|
||||
.map(api -> new ApiOptionDTO(api.getApiId(),
|
||||
StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()),
|
||||
api.getApiGroupName()))
|
||||
.sorted(Comparator.comparing(ApiOptionDTO::getApiName,
|
||||
Comparator.nullsLast(Comparator.naturalOrder())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자에게 공개된 API 의 {API ID → 노출명} 맵.
|
||||
*
|
||||
* <p>GW 인터페이스는 전부 이슈 데이터(DJB_APISTATUS_INCIDENT_API)에 들어오지만
|
||||
* 개발자포탈에 게시되는 것은 그 일부(= API)뿐이다. 화면은 이 맵에 있는 것만
|
||||
* 개별 이름으로 노출하고 나머지는 건수로 묶는다.</p>
|
||||
*
|
||||
* <p>{@link #getSelectableApis()} 와 같은 경로라 역할·소속에 따른 공개 범위가 그대로 반영된다.</p>
|
||||
*/
|
||||
public Map<String, String> getVisibleApiNames() {
|
||||
Map<String, String> names = new LinkedHashMap<>();
|
||||
for (ApiOptionDTO api : getSelectableApis()) {
|
||||
names.put(api.getApiId(), api.getApiName());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
|
||||
*
|
||||
* <p>Job 이름은 PTL_PROPERTY({@code Portal} / {@code djb.apistatus.monitor.job-name})로
|
||||
* 바꿀 수 있으며, 최초 접근 시 기본값으로 row 가 자동 생성된다.
|
||||
* Quartz 메타(QRTZ_TRIGGERS)는 EMSAPP 스키마라 EMS EntityManager 로 직접 읽는다.</p>
|
||||
*
|
||||
* <p>readOnly 를 풀어야 최초 접근 시 프로퍼티 row 자동 생성(INSERT)이 가능하다.</p>
|
||||
*/
|
||||
@Transactional
|
||||
public LocalDateTime getLastMonitorFireAt() {
|
||||
try {
|
||||
String jobName = portalPropertyService.getOrCreateProperty(
|
||||
PROPERTY_GROUP, KEY_MONITOR_JOB_NAME, DEFAULT_MONITOR_JOB_NAME,
|
||||
"API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME)");
|
||||
if (StringUtils.isBlank(jobName)) {
|
||||
jobName = DEFAULT_MONITOR_JOB_NAME;
|
||||
}
|
||||
|
||||
Object fireTime = entityManager.createNativeQuery(
|
||||
"SELECT MAX(PREV_FIRE_TIME) FROM QRTZ_TRIGGERS WHERE JOB_NAME = :jobName")
|
||||
.setParameter("jobName", jobName)
|
||||
.getSingleResult();
|
||||
if (!(fireTime instanceof Number)) {
|
||||
return null;
|
||||
}
|
||||
long epochMillis = ((Number) fireTime).longValue();
|
||||
if (epochMillis <= 0) {
|
||||
return null;
|
||||
}
|
||||
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ApiStatusSupport.ZONE);
|
||||
} catch (Exception e) {
|
||||
// 스케줄 메타 조회 실패가 화면 전체를 막지 않도록 한다
|
||||
log.warn("API 상태 모니터링 실행 시각 조회 실패", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태/이력 조회 기간(일). PTL_PROPERTY({@code Portal} / {@code djb.apistatus.window-days})로
|
||||
* 조절하며 1~365 로 clamp 한다. 값이 이상하면 기본 90일.
|
||||
*/
|
||||
@Transactional
|
||||
public int getWindowDays() {
|
||||
try {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
PROPERTY_GROUP, KEY_WINDOW_DAYS,
|
||||
String.valueOf(ApiStatusSupport.DEFAULT_WINDOW_DAYS),
|
||||
"API Status 조회 기간(일). 가동률 바·이슈 인덱스·날짜 선택 범위에 적용 (1~365)");
|
||||
int days = Integer.parseInt(StringUtils.trimToEmpty(value));
|
||||
return Math.max(MIN_WINDOW_DAYS, Math.min(MAX_WINDOW_DAYS, days));
|
||||
} catch (Exception e) {
|
||||
log.warn("조회 기간 프로퍼티 해석 실패 - 기본값 {}일 사용", ApiStatusSupport.DEFAULT_WINDOW_DAYS, e);
|
||||
return ApiStatusSupport.DEFAULT_WINDOW_DAYS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPEN API 목록(/apis) 카드에 현재 상태 태그를 노출할지 여부.
|
||||
* PTL_PROPERTY({@code Portal} / {@code djb.apistatus.api-list-status-badge}) 로 켜고 끈다.
|
||||
*
|
||||
* <p>{@code true/Y/1} 이면 노출, {@code false/N/0} 이면 숨김. 값이 비었거나 이상하면 기본 노출.</p>
|
||||
*/
|
||||
@Transactional
|
||||
public boolean isApiListStatusBadgeEnabled() {
|
||||
try {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
PROPERTY_GROUP, KEY_API_LIST_STATUS_BADGE,
|
||||
String.valueOf(DEFAULT_API_LIST_STATUS_BADGE),
|
||||
"OPEN API 목록 카드에 현재 상태(정상/점검/지연/장애) 태그 노출 여부 (true/false)");
|
||||
String normalized = StringUtils.trimToEmpty(value);
|
||||
if (StringUtils.equalsAnyIgnoreCase(normalized, "false", "n", "0")) {
|
||||
return false;
|
||||
}
|
||||
if (StringUtils.equalsAnyIgnoreCase(normalized, "true", "y", "1")) {
|
||||
return true;
|
||||
}
|
||||
return DEFAULT_API_LIST_STATUS_BADGE;
|
||||
} catch (Exception e) {
|
||||
log.warn("API 목록 상태 태그 노출 프로퍼티 조회 실패 - 기본값 {} 사용", DEFAULT_API_LIST_STATUS_BADGE, e);
|
||||
return DEFAULT_API_LIST_STATUS_BADGE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 상태가 마지막으로 <b>변경</b>된 시각 (AGWAPP.API_STATUS 최근 upsert).
|
||||
* 실행 시각(fire)과 달리 상태 변화가 있을 때만 갱신된다.
|
||||
*/
|
||||
public LocalDateTime getLastStatusChangedAt() {
|
||||
try {
|
||||
return gwApiStatusRepository.findLastModifiedDate().orElse(null);
|
||||
} catch (Exception e) {
|
||||
log.warn("API 상태 변경 시각 조회 실패", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
||||
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 전체 이슈 이력 페이지 조회 (ADR-F16/F17). 필터는 날짜와 API 두 축만 지원한다.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ApiStatusIssueHistoryService {
|
||||
|
||||
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
||||
private final ApiStatusAssembler assembler;
|
||||
private final ApiStatusCatalogService catalogService;
|
||||
|
||||
/**
|
||||
* P9 - 90일 인덱스바용 일자별 이슈 집계. 이슈가 여러 날에 걸치면 걸친 날짜 모두에 집계한다.
|
||||
*/
|
||||
public List<IssueDateEntryDTO> getIssueDates(int days, String apiId, String kind) {
|
||||
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
|
||||
LocalDateTime now = ApiStatusSupport.now();
|
||||
LocalDate today = now.toLocalDate();
|
||||
LocalDate from = today.minusDays(windowDays - 1L);
|
||||
LocalDateTime windowStart = from.atStartOfDay();
|
||||
LocalDateTime windowEnd = today.plusDays(1).atStartOfDay();
|
||||
|
||||
List<DjbApistatusIncident> incidents = findOverlapping(windowStart, windowEnd, apiId, kind);
|
||||
|
||||
Map<LocalDate, IssueDateEntryDTO> byDate = new LinkedHashMap<>();
|
||||
for (int offset = 0; offset < windowDays; offset++) {
|
||||
LocalDate date = from.plusDays(offset);
|
||||
IssueDateEntryDTO entry = new IssueDateEntryDTO();
|
||||
entry.setDate(date);
|
||||
byDate.put(date, entry);
|
||||
}
|
||||
|
||||
for (DjbApistatusIncident incident : incidents) {
|
||||
if (incident.getStartedAt() == null) {
|
||||
continue;
|
||||
}
|
||||
LocalDate start = incident.getStartedAt().toLocalDate();
|
||||
LocalDateTime effectiveEnd = incident.getEndAt() == null ? now : incident.getEndAt();
|
||||
LocalDate end = effectiveEnd.toLocalDate();
|
||||
|
||||
LocalDate cursor = start.isBefore(from) ? from : start;
|
||||
LocalDate last = end.isAfter(today) ? today : end;
|
||||
|
||||
while (!cursor.isAfter(last)) {
|
||||
IssueDateEntryDTO entry = byDate.get(cursor);
|
||||
if (entry != null) {
|
||||
tally(entry, incident);
|
||||
}
|
||||
cursor = cursor.plusDays(1);
|
||||
}
|
||||
}
|
||||
|
||||
return new ArrayList<>(byDate.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스바 1칸에 이슈 1건을 집계한다. 유형은 이슈 목록 필터와 같은 세 축(장애/지연/점검)으로 가른다.
|
||||
*/
|
||||
private void tally(IssueDateEntryDTO entry, DjbApistatusIncident incident) {
|
||||
IncidentKind kind = incident.getKind();
|
||||
if (kind == null) {
|
||||
return;
|
||||
}
|
||||
switch (kind) {
|
||||
case MAINTENANCE: entry.setMntCount(entry.getMntCount() + 1); break;
|
||||
case DELAY: entry.setDlyCount(entry.getDlyCount() + 1); break;
|
||||
default: entry.setIncCount(entry.getIncCount() + 1); break;
|
||||
}
|
||||
if (!entry.getKinds().contains(kind.name())) {
|
||||
entry.getKinds().add(kind.name());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P10 - 날짜/API/유형 필터 이슈 목록. 날짜 미지정 시 90일 전체.
|
||||
*/
|
||||
public Page<PastIssueCardDTO> getIssues(LocalDate date, String apiId, String kind, Pageable pageable) {
|
||||
LocalDateTime now = ApiStatusSupport.now();
|
||||
LocalDateTime from;
|
||||
LocalDateTime to;
|
||||
|
||||
if (date == null) {
|
||||
LocalDate today = now.toLocalDate();
|
||||
from = today.minusDays(catalogService.getWindowDays() - 1L).atStartOfDay();
|
||||
to = today.plusDays(1).atStartOfDay();
|
||||
} else {
|
||||
from = date.atStartOfDay();
|
||||
to = date.plusDays(1).atStartOfDay();
|
||||
}
|
||||
|
||||
List<DjbApistatusIncident> incidents = findOverlapping(from, to, apiId, kind);
|
||||
if (incidents.isEmpty()) {
|
||||
return new PageImpl<>(Collections.emptyList(), pageable, 0);
|
||||
}
|
||||
|
||||
int offset = (int) pageable.getOffset();
|
||||
if (offset >= incidents.size()) {
|
||||
return new PageImpl<>(Collections.emptyList(), pageable, incidents.size());
|
||||
}
|
||||
int end = Math.min(offset + pageable.getPageSize(), incidents.size());
|
||||
|
||||
List<PastIssueCardDTO> content = assembler.toPastIssueCards(incidents.subList(offset, end));
|
||||
return new PageImpl<>(content, pageable, incidents.size());
|
||||
}
|
||||
|
||||
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
|
||||
String apiId, String kind) {
|
||||
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
|
||||
? incidentQueryRepository.findVisibleOverlapping(from, to)
|
||||
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
|
||||
|
||||
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
|
||||
if (filter == null) {
|
||||
return incidents;
|
||||
}
|
||||
return incidents.stream().filter(filter).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다.
|
||||
* 필터 값은 {@link IncidentKind} 이름 그대로다 (INCIDENT / DELAY / MAINTENANCE).
|
||||
*/
|
||||
private java.util.function.Predicate<DjbApistatusIncident> kindFilter(String kind) {
|
||||
if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
IncidentKind selected = IncidentKind.valueOf(kind.trim().toUpperCase());
|
||||
return incident -> incident.getKind() == selected;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
||||
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* API Status 메인 화면 조회 (진행 중 장애 / 점검 사항 / 지난 이슈 / 이슈 상세).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ApiStatusQueryService {
|
||||
|
||||
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
||||
private final ApiStatusAssembler assembler;
|
||||
|
||||
/** P3 - 진행 중 장애·지연 */
|
||||
public List<ActiveIncidentDTO> getActiveIncidents() {
|
||||
LocalDateTime now = ApiStatusSupport.now();
|
||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
|
||||
return assembler.toActiveIncidents(incidents, now);
|
||||
}
|
||||
|
||||
/** P5 - 예정/진행 중 점검 */
|
||||
public List<MaintenanceCardDTO> getOngoingMaintenance() {
|
||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now());
|
||||
return assembler.toMaintenanceCards(incidents);
|
||||
}
|
||||
|
||||
/** P6 - 종결된 이슈 최근 N건 */
|
||||
public List<PastIssueCardDTO> getRecentClosedIssues(int size) {
|
||||
int limit = size <= 0 ? 5 : Math.min(size, 50);
|
||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||
.findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(),
|
||||
PageRequest.of(0, limit))
|
||||
.getContent();
|
||||
return assembler.toPastIssueCards(incidents);
|
||||
}
|
||||
|
||||
/** P7 - 이슈 공개 상세 */
|
||||
public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) {
|
||||
return incidentQueryRepository.findVisibleById(incidentId)
|
||||
.map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API Status 조회 서비스 공통 상수/헬퍼.
|
||||
*/
|
||||
public final class ApiStatusSupport {
|
||||
|
||||
/** 일자 경계 기준 timezone (OQ-10) */
|
||||
public static final ZoneId ZONE = ZoneId.of("Asia/Seoul");
|
||||
|
||||
/** 90일 가동률/이력 인덱스 기본 조회 일수 */
|
||||
public static final int DEFAULT_WINDOW_DAYS = 90;
|
||||
|
||||
/** 장애 종결 상태 */
|
||||
public static final List<IncidentState> CLOSED_STATES =
|
||||
Collections.unmodifiableList(Arrays.asList(IncidentState.RESOLVED, IncidentState.CANCELED));
|
||||
|
||||
public static final String STATUS_NORMAL = "NORMAL";
|
||||
public static final String STATUS_DEGRADED = "DEGRADED";
|
||||
public static final String STATUS_OUTAGE = "OUTAGE";
|
||||
public static final String STATUS_MAINTENANCE = "MAINTENANCE";
|
||||
|
||||
private ApiStatusSupport() {
|
||||
}
|
||||
|
||||
public static LocalDateTime now() {
|
||||
return LocalDateTime.now(ZONE);
|
||||
}
|
||||
|
||||
/** 지연 건인지 여부 (장애와 구분) */
|
||||
public static boolean isDelay(DjbApistatusIncident incident) {
|
||||
return incident != null && incident.getKind() == IncidentKind.DELAY;
|
||||
}
|
||||
|
||||
/** 이슈 종류에 대응하는 현재 상태 코드 */
|
||||
public static String statusOf(IncidentKind kind) {
|
||||
if (kind == null) {
|
||||
return STATUS_NORMAL;
|
||||
}
|
||||
switch (kind) {
|
||||
case INCIDENT: return STATUS_OUTAGE;
|
||||
case DELAY: return STATUS_DEGRADED;
|
||||
case MAINTENANCE: return STATUS_MAINTENANCE;
|
||||
default: return STATUS_NORMAL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 심각도 순위 (낮을수록 심각). 정렬·상태 병합에 함께 쓴다.
|
||||
*/
|
||||
public static int statusRank(String status) {
|
||||
if (status == null) {
|
||||
return 9;
|
||||
}
|
||||
switch (status) {
|
||||
case STATUS_OUTAGE: return 0;
|
||||
case STATUS_MAINTENANCE: return 1;
|
||||
case STATUS_DEGRADED: return 2;
|
||||
default: return 3;
|
||||
}
|
||||
}
|
||||
|
||||
public static String stateLabel(IncidentState state) {
|
||||
if (state == null) {
|
||||
return null;
|
||||
}
|
||||
switch (state) {
|
||||
case INVESTIGATING: return "발생";
|
||||
case IDENTIFIED: return "원인 확인";
|
||||
case MONITORING: return "모니터링";
|
||||
case RESOLVED: return "해소";
|
||||
case CANCELED: return "취소";
|
||||
default: return state.name();
|
||||
}
|
||||
}
|
||||
|
||||
public static String statusLabel(String status) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
switch (status) {
|
||||
case STATUS_NORMAL: return "정상";
|
||||
case STATUS_DEGRADED: return "지연";
|
||||
case STATUS_OUTAGE: return "장애";
|
||||
case STATUS_MAINTENANCE: return "점검";
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 시각 사이의 분. 종료가 없거나 역순이면 0.
|
||||
*/
|
||||
public static long minutesBetween(LocalDateTime from, LocalDateTime to) {
|
||||
if (from == null || to == null || !to.isAfter(from)) {
|
||||
return 0L;
|
||||
}
|
||||
return Duration.between(from, to).toMinutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* "N분 전" 형태의 상대 시간 표기.
|
||||
*/
|
||||
public static String relativeTime(LocalDateTime past, LocalDateTime now) {
|
||||
if (past == null || now == null) {
|
||||
return null;
|
||||
}
|
||||
long minutes = Duration.between(past, now).toMinutes();
|
||||
if (minutes < 1) {
|
||||
return "방금 전";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return minutes + "분 전";
|
||||
}
|
||||
long hours = minutes / 60;
|
||||
if (hours < 24) {
|
||||
long restMinutes = minutes % 60;
|
||||
return restMinutes == 0 ? hours + "시간 전" : hours + "시간 " + restMinutes + "분 전";
|
||||
}
|
||||
return (hours / 24) + "일 전";
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
|
||||
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.IncidentKind;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
||||
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 90일 가동률 집계. 일별 캐시 테이블(DJB_APISTATUS_DAILY_STAT) 없이 이슈 구간을 그때그때 합산한다.
|
||||
*
|
||||
* <p>이슈 건수가 연 1천 건 미만이므로 90일 구간을 메모리에서 합산해도 부담이 없다.
|
||||
* 같은 시간대에 겹치는 이슈는 구간 합집합으로 계산해 중복 차감을 막는다.</p>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ApiStatusUptimeService {
|
||||
|
||||
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||
|
||||
/** P2 - 90일 가동률 */
|
||||
public List<DailyStatDTO> getDailyStats(int days) {
|
||||
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
|
||||
LocalDateTime now = ApiStatusSupport.now();
|
||||
LocalDate today = now.toLocalDate();
|
||||
LocalDate from = today.minusDays(windowDays - 1L);
|
||||
|
||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay());
|
||||
|
||||
List<DailyStatDTO> result = new ArrayList<>();
|
||||
for (int offset = 0; offset < windowDays; offset++) {
|
||||
LocalDate date = from.plusDays(offset);
|
||||
result.add(buildDailyStat(date, incidents, now));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 별 90일 가동률. 장애(INCIDENT) 구간만 차감한다.
|
||||
*/
|
||||
public Map<String, Double> getApiUptimeRatios(Set<String> apiIds, int days) {
|
||||
if (apiIds == null || apiIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
|
||||
LocalDateTime now = ApiStatusSupport.now();
|
||||
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
|
||||
|
||||
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
|
||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
|
||||
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, List<long[]>> intervalsByApi = new HashMap<>();
|
||||
if (!incidents.isEmpty()) {
|
||||
Map<Long, DjbApistatusIncident> byId = incidents.stream()
|
||||
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
|
||||
|
||||
for (DjbApistatusIncidentApi api :
|
||||
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
|
||||
if (!apiIds.contains(api.getApiId())) {
|
||||
continue;
|
||||
}
|
||||
DjbApistatusIncident incident = byId.get(api.getIncidentId());
|
||||
LocalDateTime start = max(incident.getStartedAt(), windowStart);
|
||||
// 개별 API 복구 시각이 있으면 그 시점까지만 장애로 본다
|
||||
LocalDateTime end = min(firstNonNull(api.getRecoveredAt(), incident.getEndAt(), now), now);
|
||||
long minutes = ApiStatusSupport.minutesBetween(start, end);
|
||||
if (minutes <= 0) {
|
||||
continue;
|
||||
}
|
||||
intervalsByApi.computeIfAbsent(api.getApiId(), key -> new ArrayList<>())
|
||||
.add(new long[]{toEpochMinute(start), toEpochMinute(end)});
|
||||
}
|
||||
}
|
||||
|
||||
long totalMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(windowStart, now));
|
||||
Map<String, Double> ratios = new HashMap<>();
|
||||
for (String apiId : apiIds) {
|
||||
long down = unionMinutes(intervalsByApi.get(apiId));
|
||||
double ratio = (double) (totalMinutes - Math.min(down, totalMinutes)) / totalMinutes;
|
||||
ratios.put(apiId, round4(ratio));
|
||||
}
|
||||
return ratios;
|
||||
}
|
||||
|
||||
private DailyStatDTO buildDailyStat(LocalDate date, List<DjbApistatusIncident> incidents, LocalDateTime now) {
|
||||
LocalDateTime dayStart = date.atStartOfDay();
|
||||
LocalDateTime dayEnd = min(date.plusDays(1).atStartOfDay(), now);
|
||||
|
||||
DailyStatDTO dto = new DailyStatDTO();
|
||||
dto.setStatDate(date);
|
||||
|
||||
if (!dayEnd.isAfter(dayStart)) {
|
||||
// 아직 시작되지 않은 날짜 (오늘 자정 직후 등)
|
||||
dto.setUptimeRatio(1.0d);
|
||||
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
|
||||
return dto;
|
||||
}
|
||||
|
||||
// 가동률은 장애+지연을 합쳐 차감하므로 합집합 계산용 리스트를 따로 둔다.
|
||||
// (장애와 지연을 나눠 union 한 뒤 더하면 겹치는 구간이 이중 차감된다)
|
||||
List<long[]> incidentIntervals = new ArrayList<>();
|
||||
List<long[]> outageIntervals = new ArrayList<>();
|
||||
List<long[]> delayIntervals = new ArrayList<>();
|
||||
List<long[]> maintenanceIntervals = new ArrayList<>();
|
||||
Set<Long> seen = new HashSet<>();
|
||||
|
||||
for (DjbApistatusIncident incident : incidents) {
|
||||
LocalDateTime start = max(incident.getStartedAt(), dayStart);
|
||||
LocalDateTime end = min(firstNonNull(incident.getEndAt(), now), dayEnd);
|
||||
if (!end.isAfter(start)) {
|
||||
continue;
|
||||
}
|
||||
long[] interval = new long[]{toEpochMinute(start), toEpochMinute(end)};
|
||||
if (incident.getKind() == IncidentKind.MAINTENANCE) {
|
||||
maintenanceIntervals.add(interval);
|
||||
} else {
|
||||
incidentIntervals.add(interval);
|
||||
if (incident.getKind() == IncidentKind.DELAY) {
|
||||
delayIntervals.add(interval);
|
||||
} else {
|
||||
outageIntervals.add(interval);
|
||||
}
|
||||
}
|
||||
if (seen.add(incident.getIncidentId())) {
|
||||
dto.getIssues().add(new DailyStatDTO.IssueRefDTO(incident.getIncidentId(),
|
||||
incident.getKind() == null ? null : incident.getKind().name(),
|
||||
incident.getTitle()));
|
||||
}
|
||||
}
|
||||
|
||||
long dayMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(dayStart, dayEnd));
|
||||
long incidentMinutes = unionMinutes(incidentIntervals);
|
||||
long outageMinutes = unionMinutes(outageIntervals);
|
||||
long delayMinutes = unionMinutes(delayIntervals);
|
||||
long maintenanceMinutes = unionMinutes(maintenanceIntervals);
|
||||
|
||||
dto.setIncidentMinutes(incidentMinutes);
|
||||
dto.setDelayMinutes(delayMinutes);
|
||||
dto.setMaintenanceMinutes(maintenanceMinutes);
|
||||
|
||||
long downMinutes = Math.min(dayMinutes, incidentMinutes + maintenanceMinutes);
|
||||
dto.setUptimeRatio(round4((double) (dayMinutes - downMinutes) / dayMinutes));
|
||||
|
||||
// 색상은 심각한 순으로 하나만 고른다 (장애 > 점검 > 지연)
|
||||
if (outageMinutes > 0) {
|
||||
dto.setStatus(ApiStatusSupport.STATUS_OUTAGE);
|
||||
} else if (maintenanceMinutes > 0) {
|
||||
dto.setStatus(ApiStatusSupport.STATUS_MAINTENANCE);
|
||||
} else if (delayMinutes > 0) {
|
||||
dto.setStatus(ApiStatusSupport.STATUS_DEGRADED);
|
||||
} else {
|
||||
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 겹치는 구간을 합쳐 총 분을 구한다.
|
||||
*/
|
||||
private long unionMinutes(List<long[]> intervals) {
|
||||
if (intervals == null || intervals.isEmpty()) {
|
||||
return 0L;
|
||||
}
|
||||
intervals.sort((left, right) -> Long.compare(left[0], right[0]));
|
||||
|
||||
long total = 0L;
|
||||
long currentStart = intervals.get(0)[0];
|
||||
long currentEnd = intervals.get(0)[1];
|
||||
|
||||
for (int index = 1; index < intervals.size(); index++) {
|
||||
long[] interval = intervals.get(index);
|
||||
if (interval[0] > currentEnd) {
|
||||
total += currentEnd - currentStart;
|
||||
currentStart = interval[0];
|
||||
currentEnd = interval[1];
|
||||
} else if (interval[1] > currentEnd) {
|
||||
currentEnd = interval[1];
|
||||
}
|
||||
}
|
||||
total += currentEnd - currentStart;
|
||||
return total;
|
||||
}
|
||||
|
||||
private long toEpochMinute(LocalDateTime dateTime) {
|
||||
return dateTime.atZone(ApiStatusSupport.ZONE).toEpochSecond() / 60L;
|
||||
}
|
||||
|
||||
private LocalDateTime max(LocalDateTime left, LocalDateTime right) {
|
||||
if (left == null) {
|
||||
return right;
|
||||
}
|
||||
return left.isAfter(right) ? left : right;
|
||||
}
|
||||
|
||||
private LocalDateTime min(LocalDateTime left, LocalDateTime right) {
|
||||
if (left == null) {
|
||||
return right;
|
||||
}
|
||||
return left.isBefore(right) ? left : right;
|
||||
}
|
||||
|
||||
private LocalDateTime firstNonNull(LocalDateTime... candidates) {
|
||||
for (LocalDateTime candidate : candidates) {
|
||||
if (candidate != null) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private double round4(double value) {
|
||||
double clamped = Math.max(0.0d, Math.min(1.0d, value));
|
||||
return Math.round(clamped * 10000.0d) / 10000.0d;
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.service;
|
||||
|
||||
import com.eactive.apim.portal.app.entity.Credential;
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* 로그인 사용자가 이용 중인 API 의 현재 상태 (P4).
|
||||
*
|
||||
* <p>대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.
|
||||
* 상태 판정은 {@link ApiCurrentStatusService} 에 위임해 API 상세 화면과 기준을 맞춘다.</p>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class MyApiStatusQueryService {
|
||||
|
||||
private final CredentialRepository credentialRepository;
|
||||
private final ApiCurrentStatusService apiCurrentStatusService;
|
||||
private final ApiStatusUptimeService uptimeService;
|
||||
private final ApiStatusCatalogService catalogService;
|
||||
|
||||
public List<MyApiStatusDTO> getMyApiStatuses() {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user == null || user.getPortalOrg() == null || StringUtils.isBlank(user.getPortalOrg().getId())) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<String, String> myApis = collectMyApis(user.getPortalOrg().getId());
|
||||
if (myApis.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<String, ApiCurrentStatusDTO> statuses = apiCurrentStatusService.resolveStatuses(myApis.keySet());
|
||||
Map<String, Double> uptimes =
|
||||
uptimeService.getApiUptimeRatios(myApis.keySet(), catalogService.getWindowDays());
|
||||
|
||||
List<MyApiStatusDTO> result = new ArrayList<>();
|
||||
for (Map.Entry<String, String> entry : myApis.entrySet()) {
|
||||
String apiId = entry.getKey();
|
||||
ApiCurrentStatusDTO status = statuses.get(apiId);
|
||||
|
||||
MyApiStatusDTO dto = new MyApiStatusDTO();
|
||||
dto.setApiId(apiId);
|
||||
// 기관이 발급받은 앱 기준 목록이므로 API 명은 credential 쪽 값을 그대로 쓴다
|
||||
dto.setApiName(entry.getValue());
|
||||
dto.setCurrentStatus(status == null ? ApiStatusSupport.STATUS_NORMAL : status.getCurrentStatus());
|
||||
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
|
||||
dto.setActiveIncidentId(status == null ? null : status.getActiveIncidentId());
|
||||
dto.setLastIncidentAt(status == null ? null : status.getLastIncidentAt());
|
||||
dto.setUptime90d(uptimes.getOrDefault(apiId, 1.0d));
|
||||
result.add(dto);
|
||||
}
|
||||
|
||||
result.sort(Comparator
|
||||
.comparingInt((MyApiStatusDTO dto) -> ApiStatusSupport.statusRank(dto.getCurrentStatus()))
|
||||
.thenComparing(MyApiStatusDTO::getApiName, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기관이 보유한 앱들의 API 목록 (중복 제거, API 명 기준 정렬)
|
||||
*/
|
||||
private Map<String, String> collectMyApis(String orgId) {
|
||||
Map<String, String> myApis = new TreeMap<>();
|
||||
for (Credential credential : credentialRepository.findAllByOrgid(orgId)) {
|
||||
if (credential.getApiList() == null) {
|
||||
continue;
|
||||
}
|
||||
for (ApiSpecInfo api : credential.getApiList()) {
|
||||
if (StringUtils.isBlank(api.getApiId())) {
|
||||
continue;
|
||||
}
|
||||
myApis.put(api.getApiId(), StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()));
|
||||
}
|
||||
}
|
||||
return myApis;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.eactive.apim.portal.djb.footer;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* 푸터 "관련 사이트" 셀렉트에 노출되는 사이트 한 건.
|
||||
*
|
||||
* <p>{@link RelatedSiteService} 가 PortalProperty 문자열을 파싱해 만든다.</p>
|
||||
*/
|
||||
@Getter
|
||||
@ToString
|
||||
@AllArgsConstructor
|
||||
public class RelatedSite {
|
||||
|
||||
/** 셀렉트에 표시되는 이름 (예: 신한은행) */
|
||||
private final String name;
|
||||
|
||||
/** 이동 대상 URL (http/https 절대주소 또는 `/` 로 시작하는 사이트 상대주소) */
|
||||
private final String url;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.eactive.apim.portal.djb.footer;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 푸터 "관련 사이트" 셀렉트의 라벨과 목록을 DB(PortalProperty)에서 조회한다.
|
||||
*
|
||||
* <p>group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty}
|
||||
* 의 자동 생성(self-seed)이 동작하도록 한다.</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code footer.related-sites.label} - 셀렉트 첫 항목(플레이스홀더) 문구</li>
|
||||
* <li>{@code footer.related-sites} - 사이트 목록. 한 줄에 하나씩 {@code 이름=URL}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>목록 표기 규칙</h3>
|
||||
* <pre>
|
||||
* # 주석 (# 으로 시작하는 줄은 무시)
|
||||
* 신한은행=http://www.shinhan.com
|
||||
* 제주은행 = https://www.jejubank.co.kr ← = 앞뒤 공백 허용
|
||||
* </pre>
|
||||
* <ul>
|
||||
* <li>빈 줄 · {@code #} 로 시작하는 줄은 건너뛴다.</li>
|
||||
* <li>줄 순서가 곧 화면 노출 순서다.</li>
|
||||
* <li>URL 에 {@code =} 가 들어가도 <b>첫 번째</b> {@code =} 만 구분자로 쓰므로 안전하다.</li>
|
||||
* <li>{@code =} 가 없거나 이름/URL 이 비었거나 허용되지 않은 스킴이면 그 줄만 버리고 WARN 로그를
|
||||
* 남긴다. (한 줄이 잘못돼도 나머지 사이트는 정상 노출)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>허용 스킴을 {@code http/https} 와 사이트 상대경로로 제한하는 이유는, 이 값이 관리자 화면에서
|
||||
* 편집되어 그대로 앵커/스크립트 이동 대상이 되기 때문이다({@code javascript:} 등 차단).</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RelatedSiteService {
|
||||
|
||||
private static final String GROUP = "Portal";
|
||||
|
||||
static final String NAME_LABEL = "footer.related-sites.label";
|
||||
static final String NAME_SITES = "footer.related-sites";
|
||||
|
||||
static final String DESC_LABEL = "푸터 관련 사이트 셀렉트 라벨(첫 항목 문구)";
|
||||
static final String DESC_SITES = "푸터 관련 사이트 목록. 한 줄에 하나씩 '이름=URL' (빈 줄·# 주석 무시)";
|
||||
|
||||
static final String DEFAULT_LABEL = "DJBank 관련 사이트";
|
||||
|
||||
/** 기본값: 신한금융그룹 Family site (shinhangroup.com 하단 목록 기준) */
|
||||
static final String DEFAULT_SITES = String.join("\n",
|
||||
"신한은행=http://www.shinhan.com",
|
||||
"신한카드=http://www.shinhancard.com",
|
||||
"신한투자증권=http://www.shinhansec.com",
|
||||
"신한라이프=http://www.shinhanlife.co.kr",
|
||||
"신한캐피탈=http://www.shcap.co.kr",
|
||||
"신한자산운용=https://www.shinhanfund.com",
|
||||
"제주은행=https://www.jejubank.co.kr",
|
||||
"신한저축은행=http://www.shinhansavings.co.kr",
|
||||
"신한자산신탁=http://www.shinhantrust.kr",
|
||||
"신한DS=http://www.shinhansys.co.kr",
|
||||
"신한펀드파트너스=https://www.shinhanfundpartners.com",
|
||||
"신한리츠운용=http://shinhanrem.com",
|
||||
"신한벤처투자=http://www.shinhanvc.com",
|
||||
"신한EZ손해보험=http://www.shinhanez.co.kr",
|
||||
"신한장학재단=http://www.shsf.or.kr",
|
||||
"신한금융희망재단=http://www.shinhanfoundation.or.kr");
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 셀렉트 첫 항목에 노출할 라벨 */
|
||||
@Transactional
|
||||
public String getLabel() {
|
||||
String label = portalPropertyService.getOrCreateProperty(GROUP, NAME_LABEL, DEFAULT_LABEL, DESC_LABEL);
|
||||
return (label == null || label.trim().isEmpty()) ? DEFAULT_LABEL : label.trim();
|
||||
}
|
||||
|
||||
/** 셀렉트에 노출할 사이트 목록. 파싱 결과가 없으면 빈 리스트(푸터에서 셀렉트 미노출) */
|
||||
@Transactional
|
||||
public List<RelatedSite> getSites() {
|
||||
return parse(portalPropertyService.getOrCreateProperty(GROUP, NAME_SITES, DEFAULT_SITES, DESC_SITES));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code 이름=URL} 줄 목록을 파싱한다. 잘못된 줄은 건너뛴다.
|
||||
*/
|
||||
static List<RelatedSite> parse(String raw) {
|
||||
if (raw == null || raw.trim().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<RelatedSite> sites = new ArrayList<>();
|
||||
for (String rawLine : raw.split("\\r?\\n")) {
|
||||
String line = rawLine.trim();
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int sep = line.indexOf('=');
|
||||
if (sep <= 0) {
|
||||
log.warn("관련 사이트 설정 형식 오류(= 구분자 없음) - 해당 줄 무시: {}", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
String name = line.substring(0, sep).trim();
|
||||
String url = line.substring(sep + 1).trim();
|
||||
if (name.isEmpty() || url.isEmpty()) {
|
||||
log.warn("관련 사이트 설정 형식 오류(이름 또는 URL 없음) - 해당 줄 무시: {}", line);
|
||||
continue;
|
||||
}
|
||||
if (!isAllowedUrl(url)) {
|
||||
log.warn("관련 사이트 설정 URL 스킴 불허(http/https 또는 / 로 시작해야 함) - 해당 줄 무시: {}", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
sites.add(new RelatedSite(name, url));
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
private static boolean isAllowedUrl(String url) {
|
||||
String lower = url.toLowerCase();
|
||||
return lower.startsWith("http://") || lower.startsWith("https://") || url.startsWith("/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 메뉴 접근 권한(ACCESS_ROLES) 서버측 집행.
|
||||
*
|
||||
* <p>요청 경로가 접근 권한이 설정된 메뉴 경로와 정확히 일치할 때만 검사한다
|
||||
* (하위 경로는 기존 안전망 @Secured / PageRoute.role 에 위임 — 사용자 결정).
|
||||
* 미충족 시 익명은 로그인으로, 인증 사용자는 홈으로 리다이렉트한다.
|
||||
* 메뉴에 등록되지 않은 경로는 통과시킨다.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class MenuAccessInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final MenuService menuService;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
String path = currentPath(request);
|
||||
List<String> requiredRoles = menuService.getSnapshot().getAccessRolesByPath().get(path);
|
||||
if (requiredRoles == null || requiredRoles.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (isAllowed(requiredRoles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
response.sendRedirect(request.getContextPath() + "/login");
|
||||
} else {
|
||||
log.info("메뉴 접근 권한 미충족 - path: {}, 필요 권한: {}, 사용자: {}",
|
||||
path, requiredRoles, SecurityUtil.getCurrentLoginId());
|
||||
response.sendRedirect(request.getContextPath() + "/");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isAllowed(List<String> requiredRoles) {
|
||||
for (String role : requiredRoles) {
|
||||
if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) {
|
||||
if (SecurityUtil.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
} else if (SecurityUtil.hasRole(role)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String currentPath(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
if (contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath)) {
|
||||
uri = uri.substring(contextPath.length());
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
|
||||
*
|
||||
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
|
||||
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
|
||||
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
|
||||
* 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
|
||||
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.
|
||||
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
|
||||
*
|
||||
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/internal/menu")
|
||||
@RequiredArgsConstructor
|
||||
public class MenuInternalController {
|
||||
|
||||
static final String PROP_GROUP = "Portal";
|
||||
static final String PROP_ALLOW_IPS = "menu.internal.allow-ips";
|
||||
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
|
||||
static final String PROP_ALLOW_IPS_DESCRIPTION =
|
||||
"메뉴 내부 API(리로드) 허용 IP 목록. 콤마(,)/세미콜론(;)/줄바꿈 구분, "
|
||||
+ "정확일치·IPv4 CIDR(172.30.1.0/24)·와일드카드(172.30.*.*) 지원";
|
||||
|
||||
private final MenuService menuService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
@PostMapping("/reload")
|
||||
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
|
||||
if (!isAllowed(request)) {
|
||||
Map<String, Object> denied = new LinkedHashMap<>();
|
||||
denied.put("result", "DENIED");
|
||||
denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)");
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied);
|
||||
}
|
||||
|
||||
MenuService.MenuSnapshot snapshot = menuService.reload();
|
||||
log.info("메뉴 캐시 리로드 명령 수신 - from: {}", request.getRemoteAddr());
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("result", "OK");
|
||||
body.put("itemCount", snapshot.getItemCount());
|
||||
body.put("reloadedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
|
||||
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
|
||||
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
|
||||
*/
|
||||
private boolean isAllowed(HttpServletRequest request) {
|
||||
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
|
||||
if (viaProxy || !IpAddressMatcher.matches(resolveAllowIps(), remote)) {
|
||||
log.warn("메뉴 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String resolveAllowIps() {
|
||||
try {
|
||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
|
||||
DEFAULT_ALLOW_IPS, PROP_ALLOW_IPS_DESCRIPTION);
|
||||
} catch (Exception e) {
|
||||
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
||||
return DEFAULT_ALLOW_IPS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 모든 뷰 요청에 role 필터가 적용된 {@code menuView} 를 주입한다
|
||||
* (breadcrumb 의 GlobalControllerAdvice 와 동일한 방식).
|
||||
*
|
||||
* <p>노출 판정: EXPOSE_ROLES 비어있음(전체) ∥ AUTHENTICATED(로그인) ∥
|
||||
* 역할 any-of. 경로 없는 그룹은 노출 자식이 하나도 없으면 제외한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@ControllerAdvice
|
||||
@RequiredArgsConstructor
|
||||
public class MenuModelAdvice {
|
||||
|
||||
private final MenuService menuService;
|
||||
|
||||
@ModelAttribute("menuView")
|
||||
public MenuView menuView(HttpServletRequest request) {
|
||||
try {
|
||||
MenuService.MenuSnapshot snapshot = menuService.getSnapshot();
|
||||
boolean authenticated = SecurityUtil.isAuthenticated();
|
||||
|
||||
List<MenuNode> gnb = new ArrayList<>();
|
||||
MenuNode mypage = null;
|
||||
for (MenuNode root : snapshot.getRoots()) {
|
||||
MenuNode filtered = filter(root, authenticated);
|
||||
if (filtered == null) {
|
||||
continue;
|
||||
}
|
||||
if (PortalMenuItem.SECTION_MYPAGE.equals(filtered.getSection())) {
|
||||
if (mypage == null) {
|
||||
mypage = filtered;
|
||||
}
|
||||
} else {
|
||||
gnb.add(filtered);
|
||||
}
|
||||
}
|
||||
return new MenuView(gnb, mypage, request.getRequestURI());
|
||||
} catch (Exception e) {
|
||||
// 메뉴 조회 실패가 화면 전체를 막지 않도록 빈 메뉴로 폴백
|
||||
log.error("메뉴 뷰 구성 실패 - 빈 메뉴로 렌더링합니다.", e);
|
||||
return MenuView.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private MenuNode filter(MenuNode node, boolean authenticated) {
|
||||
if (!isExposed(node, authenticated)) {
|
||||
return null;
|
||||
}
|
||||
MenuNode copy = node.copyWithoutChildren();
|
||||
List<MenuNode> children = new ArrayList<>();
|
||||
for (MenuNode child : node.getChildren()) {
|
||||
MenuNode filteredChild = filter(child, authenticated);
|
||||
if (filteredChild != null) {
|
||||
children.add(filteredChild);
|
||||
}
|
||||
}
|
||||
copy.setChildren(children);
|
||||
|
||||
// 경로 없는 그룹은 노출할 자식이 없으면 통째로 제외
|
||||
if (copy.isGroup() && !copy.hasPath() && children.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private boolean isExposed(MenuNode node, boolean authenticated) {
|
||||
List<String> exposeRoles = node.getExposeRoles();
|
||||
if (exposeRoles.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (String role : exposeRoles) {
|
||||
if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) {
|
||||
if (authenticated) {
|
||||
return true;
|
||||
}
|
||||
} else if (SecurityUtil.hasRole(role)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 렌더용 메뉴 노드. {@link MenuService} 스냅샷 트리와
|
||||
* {@link MenuModelAdvice} 의 요청별 필터 사본 양쪽에 사용한다.
|
||||
*/
|
||||
@Data
|
||||
public class MenuNode {
|
||||
|
||||
private String menuId;
|
||||
private String name;
|
||||
/** 이동 경로 (null = 클릭 없는 그룹) */
|
||||
private String path;
|
||||
private boolean group;
|
||||
/** GNB | MYPAGE */
|
||||
private String section;
|
||||
private String icon;
|
||||
private boolean newWindow;
|
||||
private List<String> exposeRoles = new ArrayList<>();
|
||||
private List<String> accessRoles = new ArrayList<>();
|
||||
private List<MenuNode> children = new ArrayList<>();
|
||||
|
||||
/** 필터 사본 생성 (children 제외 필드 복사) */
|
||||
public MenuNode copyWithoutChildren() {
|
||||
MenuNode copy = new MenuNode();
|
||||
copy.menuId = menuId;
|
||||
copy.name = name;
|
||||
copy.path = path;
|
||||
copy.group = group;
|
||||
copy.section = section;
|
||||
copy.icon = icon;
|
||||
copy.newWindow = newWindow;
|
||||
copy.exposeRoles = exposeRoles;
|
||||
copy.accessRoles = accessRoles;
|
||||
return copy;
|
||||
}
|
||||
|
||||
public boolean hasPath() {
|
||||
return path != null && !path.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
|
||||
import com.eactive.apim.portal.menu.entity.PortalRole;
|
||||
import com.eactive.apim.portal.menu.entity.PortalRoleAuthority;
|
||||
import com.eactive.apim.portal.menu.entity.PortalRoleAuthorityId;
|
||||
import com.eactive.apim.portal.menu.repository.PortalMenuItemRepository;
|
||||
import com.eactive.apim.portal.menu.repository.PortalMenuPlacementRepository;
|
||||
import com.eactive.apim.portal.menu.repository.PortalRoleAuthorityRepository;
|
||||
import com.eactive.apim.portal.menu.repository.PortalRoleRepository;
|
||||
import com.eactive.apim.portal.menu.service.PortalMenuDataService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 부팅 시 menu.yml / roles.yml → DB 적재.
|
||||
*
|
||||
* <ul>
|
||||
* <li>역할: PTL_ROLE upsert + PTL_ROLE_AUTHORITY 재구축 (roles.yml 미러)</li>
|
||||
* <li>메뉴 항목: id 기준 upsert — yml 기본값이 바뀌면 DFLT_* 를 갱신하고,
|
||||
* 관리자가 수정하지 않은(현재값==구 기본값) 필드만 새 기본값을 따라간다</li>
|
||||
* <li>배치: PTL_MENU_PLACEMENT 가 비어있을 때만 DFLT_* 로 최초 시딩</li>
|
||||
* <li>yml 에서 사라진 PORTAL 항목은 경고 로그만 남기고 삭제하지 않는다</li>
|
||||
* </ul>
|
||||
*
|
||||
* 부팅 컨텍스트에는 인증 주체가 없어 감사(@CreatedBy)가 비므로,
|
||||
* 신규 행은 {@code createdBy=SYSTEM} 을 명시 세팅한다
|
||||
* (AuditingHandler 는 auditor 부재 시 기존 값을 보존).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MenuSeeder implements ApplicationListener<ApplicationReadyEvent> {
|
||||
|
||||
static final String SYSTEM_AUDITOR = "SYSTEM";
|
||||
private static final Pattern MENU_ID_PATTERN = Pattern.compile("^[a-z0-9-]+$");
|
||||
|
||||
private final PortalMenuYmlProperties menuYmlProperties;
|
||||
private final PortalRolesProperties rolesProperties;
|
||||
private final PortalMenuItemRepository menuItemRepository;
|
||||
private final PortalMenuPlacementRepository menuPlacementRepository;
|
||||
private final PortalRoleRepository roleRepository;
|
||||
private final PortalRoleAuthorityRepository roleAuthorityRepository;
|
||||
private final PortalMenuDataService menuDataService;
|
||||
private final MenuService menuService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||
try {
|
||||
seedRoles();
|
||||
seedMenuItems();
|
||||
seedPlacementsIfEmpty();
|
||||
menuService.reload();
|
||||
} catch (Exception e) {
|
||||
// 시딩 실패가 기동 자체를 막지 않도록 로그만 남긴다 (메뉴는 기존 DB 값으로 동작)
|
||||
log.error("메뉴/역할 시딩 실패 - 기존 DB 데이터로 동작합니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── 역할 ───────────────
|
||||
|
||||
private void seedRoles() {
|
||||
int sortOrder = 10;
|
||||
int upserted = 0;
|
||||
Set<String> definedCodes = new HashSet<>();
|
||||
|
||||
for (PortalRolesProperties.RoleDef roleDef : rolesProperties.getRoles()) {
|
||||
upserted += upsertRole(roleDef.getCode(), roleDef.getName(), PortalRole.TYPE_BASE, sortOrder) ? 1 : 0;
|
||||
definedCodes.add(roleDef.getCode());
|
||||
sortOrder += 10;
|
||||
}
|
||||
for (Map.Entry<String, String> entry : rolesProperties.getAuthorityNames().entrySet()) {
|
||||
if (definedCodes.contains(entry.getKey())) {
|
||||
continue;
|
||||
}
|
||||
upserted += upsertRole(entry.getKey(), entry.getValue(), PortalRole.TYPE_AUTHORITY, sortOrder) ? 1 : 0;
|
||||
sortOrder += 10;
|
||||
}
|
||||
|
||||
// 역할→권한 매핑은 순수 미러 — 통째로 재구축
|
||||
roleAuthorityRepository.deleteAllInBatch();
|
||||
roleAuthorityRepository.flush();
|
||||
List<PortalRoleAuthority> mappings = rolesProperties.getRoles().stream()
|
||||
.flatMap(roleDef -> roleDef.getAuthorities().stream()
|
||||
.map(authority -> {
|
||||
PortalRoleAuthority mapping = new PortalRoleAuthority();
|
||||
mapping.setId(new PortalRoleAuthorityId(roleDef.getCode(), authority));
|
||||
mapping.setCreatedBy(SYSTEM_AUDITOR);
|
||||
return mapping;
|
||||
}))
|
||||
.collect(Collectors.toList());
|
||||
roleAuthorityRepository.saveAll(mappings);
|
||||
|
||||
log.info("역할 시딩 완료 - PTL_ROLE upsert {}건, PTL_ROLE_AUTHORITY {}건 재구축", upserted, mappings.size());
|
||||
}
|
||||
|
||||
private boolean upsertRole(String code, String name, String type, int sortOrder) {
|
||||
PortalRole role = roleRepository.findById(code).orElse(null);
|
||||
if (role == null) {
|
||||
role = new PortalRole();
|
||||
role.setRoleCode(code);
|
||||
role.setCreatedBy(SYSTEM_AUDITOR);
|
||||
} else if (Objects.equals(role.getRoleName(), name)
|
||||
&& Objects.equals(role.getRoleType(), type)
|
||||
&& Objects.equals(role.getSortOrder(), sortOrder)) {
|
||||
return false;
|
||||
}
|
||||
role.setRoleName(name);
|
||||
role.setRoleType(type);
|
||||
role.setSortOrder(sortOrder);
|
||||
roleRepository.save(role);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────── 메뉴 항목 ───────────────
|
||||
|
||||
private void seedMenuItems() {
|
||||
List<FlatMenuDef> flatDefs = flatten(menuYmlProperties.getItems());
|
||||
validate(flatDefs);
|
||||
|
||||
int inserted = 0;
|
||||
int updated = 0;
|
||||
for (FlatMenuDef def : flatDefs) {
|
||||
PortalMenuItem existing = menuItemRepository.findById(def.id).orElse(null);
|
||||
if (existing == null) {
|
||||
menuItemRepository.save(newItem(def));
|
||||
inserted++;
|
||||
} else if (updateItem(existing, def)) {
|
||||
menuItemRepository.save(existing);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
// yml 에서 사라진 PORTAL 항목 경고 (자동 삭제 금지)
|
||||
Set<String> ymlIds = flatDefs.stream().map(def -> def.id).collect(Collectors.toSet());
|
||||
menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL).stream()
|
||||
.map(PortalMenuItem::getMenuId)
|
||||
.filter(id -> !ymlIds.contains(id))
|
||||
.forEach(id -> log.warn("menu.yml 에 없는 기본 메뉴 항목이 DB에 남아 있습니다 (수동 정리 필요): {}", id));
|
||||
|
||||
log.info("메뉴 항목 시딩 완료 - 신규 {}건, 갱신 {}건, 유지 {}건",
|
||||
inserted, updated, flatDefs.size() - inserted - updated);
|
||||
}
|
||||
|
||||
private PortalMenuItem newItem(FlatMenuDef def) {
|
||||
PortalMenuItem item = new PortalMenuItem();
|
||||
item.setMenuId(def.id);
|
||||
item.setSourceType(PortalMenuItem.SOURCE_PORTAL);
|
||||
item.setCreatedBy(SYSTEM_AUDITOR);
|
||||
|
||||
item.setMenuName(def.name());
|
||||
item.setMenuPath(def.path());
|
||||
item.setExposeRoles(def.exposeCsv());
|
||||
item.setAccessRoles(def.accessCsv());
|
||||
|
||||
applyStructure(item, def);
|
||||
applyDefaults(item, def);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 항목 upsert 갱신. 내용 필드는 "관리자 미수정(현재값==구 기본값) 시에만"
|
||||
* 새 기본값을 따라가고, 구조 필드(group/section/icon/new-window)와 DFLT_* 는
|
||||
* 항상 yml 값으로 맞춘다.
|
||||
*
|
||||
* @return 변경이 있었으면 true
|
||||
*/
|
||||
private boolean updateItem(PortalMenuItem item, FlatMenuDef def) {
|
||||
boolean[] changed = {false};
|
||||
|
||||
followDefault(item, changed, def.name(), PortalMenuItem::getDfltMenuName,
|
||||
PortalMenuItem::getMenuName, PortalMenuItem::setMenuName, PortalMenuItem::setDfltMenuName);
|
||||
followDefault(item, changed, def.path(), PortalMenuItem::getDfltMenuPath,
|
||||
PortalMenuItem::getMenuPath, PortalMenuItem::setMenuPath, PortalMenuItem::setDfltMenuPath);
|
||||
followDefault(item, changed, def.exposeCsv(), PortalMenuItem::getDfltExposeRoles,
|
||||
PortalMenuItem::getExposeRoles, PortalMenuItem::setExposeRoles, PortalMenuItem::setDfltExposeRoles);
|
||||
followDefault(item, changed, def.accessCsv(), PortalMenuItem::getDfltAccessRoles,
|
||||
PortalMenuItem::getAccessRoles, PortalMenuItem::setAccessRoles, PortalMenuItem::setDfltAccessRoles);
|
||||
|
||||
String groupYn = def.source.isGroup() ? "Y" : "N";
|
||||
String section = def.section();
|
||||
String newWindowYn = def.source.isNewWindow() ? "Y" : "N";
|
||||
if (!Objects.equals(item.getGroupYn(), groupYn)
|
||||
|| !Objects.equals(item.getMenuSection(), section)
|
||||
|| !Objects.equals(item.getIconClass(), def.source.getIcon())
|
||||
|| !Objects.equals(item.getNewWindowYn(), newWindowYn)) {
|
||||
applyStructure(item, def);
|
||||
changed[0] = true;
|
||||
}
|
||||
|
||||
if (!Objects.equals(item.getDfltParentId(), def.parentId)
|
||||
|| !Objects.equals(item.getDfltSortOrder(), def.sortOrder)) {
|
||||
item.setDfltParentId(def.parentId);
|
||||
item.setDfltSortOrder(def.sortOrder);
|
||||
changed[0] = true;
|
||||
}
|
||||
return changed[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 내용 필드 1개에 대한 기본값 추종 처리: yml 기본값이 바뀌었을 때
|
||||
* 현재값이 구 기본값과 같으면(관리자 미수정) 현재값도 새 기본값으로 갱신한다.
|
||||
*/
|
||||
private void followDefault(PortalMenuItem item, boolean[] changed, String newDefault,
|
||||
Function<PortalMenuItem, String> defaultGetter,
|
||||
Function<PortalMenuItem, String> currentGetter,
|
||||
BiConsumer<PortalMenuItem, String> currentSetter,
|
||||
BiConsumer<PortalMenuItem, String> defaultSetter) {
|
||||
String oldDefault = defaultGetter.apply(item);
|
||||
if (Objects.equals(oldDefault, newDefault)) {
|
||||
return;
|
||||
}
|
||||
if (Objects.equals(currentGetter.apply(item), oldDefault)) {
|
||||
currentSetter.accept(item, newDefault);
|
||||
}
|
||||
defaultSetter.accept(item, newDefault);
|
||||
changed[0] = true;
|
||||
}
|
||||
|
||||
private void applyStructure(PortalMenuItem item, FlatMenuDef def) {
|
||||
item.setGroupYn(def.source.isGroup() ? "Y" : "N");
|
||||
item.setMenuSection(def.section());
|
||||
item.setIconClass(def.source.getIcon());
|
||||
item.setNewWindowYn(def.source.isNewWindow() ? "Y" : "N");
|
||||
}
|
||||
|
||||
private void applyDefaults(PortalMenuItem item, FlatMenuDef def) {
|
||||
item.setDfltMenuName(def.name());
|
||||
item.setDfltMenuPath(def.path());
|
||||
item.setDfltExposeRoles(def.exposeCsv());
|
||||
item.setDfltAccessRoles(def.accessCsv());
|
||||
item.setDfltParentId(def.parentId);
|
||||
item.setDfltSortOrder(def.sortOrder);
|
||||
item.setDfltVisibleYn("Y");
|
||||
}
|
||||
|
||||
// ─────────────── 배치 ───────────────
|
||||
|
||||
private void seedPlacementsIfEmpty() {
|
||||
if (menuPlacementRepository.count() > 0) {
|
||||
log.info("메뉴 배치 존재 - 최초 시딩 건너뜀 (관리자 배치 보존)");
|
||||
return;
|
||||
}
|
||||
List<PortalMenuItem> portalItems =
|
||||
menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL);
|
||||
List<PortalMenuPlacement> placements = menuDataService.buildDefaultPlacements(portalItems);
|
||||
placements.forEach(placement -> placement.setCreatedBy(SYSTEM_AUDITOR));
|
||||
menuPlacementRepository.saveAll(placements);
|
||||
log.info("메뉴 배치 최초 시딩 완료 - {}건", placements.size());
|
||||
}
|
||||
|
||||
// ─────────────── 평탄화 / 검증 ───────────────
|
||||
|
||||
private List<FlatMenuDef> flatten(List<PortalMenuYmlProperties.MenuItemDef> roots) {
|
||||
List<FlatMenuDef> result = new ArrayList<>();
|
||||
int sortOrder = 10;
|
||||
for (PortalMenuYmlProperties.MenuItemDef root : roots) {
|
||||
result.add(new FlatMenuDef(root, null, sortOrder, null));
|
||||
int childOrder = 10;
|
||||
for (PortalMenuYmlProperties.MenuItemDef child : root.getChildren()) {
|
||||
result.add(new FlatMenuDef(child, root.getId(), childOrder, root));
|
||||
childOrder += 10;
|
||||
}
|
||||
sortOrder += 10;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void validate(List<FlatMenuDef> defs) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (FlatMenuDef def : defs) {
|
||||
if (def.id == null || !MENU_ID_PATTERN.matcher(def.id).matches()) {
|
||||
throw new IllegalStateException("menu.yml 항목 id 형식 오류(kebab-case 필수): " + def.id);
|
||||
}
|
||||
if (!seen.add(def.id)) {
|
||||
throw new IllegalStateException("menu.yml 항목 id 중복: " + def.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** yml 트리 항목의 평탄화 뷰 (부모/기본 정렬 포함) */
|
||||
private static class FlatMenuDef {
|
||||
final PortalMenuYmlProperties.MenuItemDef source;
|
||||
final String parentId;
|
||||
final Integer sortOrder;
|
||||
final PortalMenuYmlProperties.MenuItemDef parent;
|
||||
final String id;
|
||||
|
||||
FlatMenuDef(PortalMenuYmlProperties.MenuItemDef source, String parentId, Integer sortOrder,
|
||||
PortalMenuYmlProperties.MenuItemDef parent) {
|
||||
this.source = source;
|
||||
this.parentId = parentId;
|
||||
this.sortOrder = sortOrder;
|
||||
this.parent = parent;
|
||||
this.id = source.getId();
|
||||
}
|
||||
|
||||
String name() {
|
||||
return source.getName();
|
||||
}
|
||||
|
||||
String path() {
|
||||
return trimToNull(source.getPath());
|
||||
}
|
||||
|
||||
/** 자식은 섹션 미지정 시 부모 섹션 상속 */
|
||||
String section() {
|
||||
String own = trimToNull(source.getSection());
|
||||
if (own != null) {
|
||||
return own;
|
||||
}
|
||||
if (parent != null) {
|
||||
String parentSection = trimToNull(parent.getSection());
|
||||
if (parentSection != null) {
|
||||
return parentSection;
|
||||
}
|
||||
}
|
||||
return PortalMenuItem.SECTION_GNB;
|
||||
}
|
||||
|
||||
String exposeCsv() {
|
||||
return toCsv(source.getExposeRoles());
|
||||
}
|
||||
|
||||
String accessCsv() {
|
||||
return toCsv(source.getAccessRoles());
|
||||
}
|
||||
|
||||
private static String toCsv(List<String> roles) {
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return String.join(",", roles);
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
|
||||
import com.eactive.apim.portal.menu.service.PortalMenuDataService;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 메뉴 트리 스냅샷 캐시.
|
||||
*
|
||||
* <p>매 요청 DB 조회를 피하기 위해 role 비의존 트리를 메모리에 유지한다
|
||||
* (기존 수제 캐시 관례: PageService 의 volatile 필드). TTL 은
|
||||
* PTL_PROPERTY {@code Portal / menu.cache.ttl-seconds} (기본 3600초)이며,
|
||||
* eapim-admin 의 reload 명령(/internal/menu/reload)으로 즉시 갱신된다.
|
||||
* 요청별 role 필터링은 {@link MenuModelAdvice} 가 수행한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MenuService {
|
||||
|
||||
static final String PROP_GROUP = "Portal";
|
||||
static final String PROP_CACHE_TTL = "menu.cache.ttl-seconds";
|
||||
static final String DEFAULT_CACHE_TTL = "3600";
|
||||
/** 스냅샷이 비어있을 때의 재시도 TTL(초) — 시딩 전 기동 직후 요청 대비 */
|
||||
private static final long EMPTY_RETRY_TTL_SECONDS = 60;
|
||||
|
||||
private final PortalMenuDataService menuDataService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
private volatile MenuSnapshot snapshot;
|
||||
|
||||
public MenuSnapshot getSnapshot() {
|
||||
MenuSnapshot current = snapshot;
|
||||
if (current != null && !current.isExpired()) {
|
||||
return current;
|
||||
}
|
||||
synchronized (this) {
|
||||
current = snapshot;
|
||||
if (current != null && !current.isExpired()) {
|
||||
return current;
|
||||
}
|
||||
return reload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 에서 트리를 재구축한다. 시더 완료 시점과 admin reload 명령이 호출한다.
|
||||
*/
|
||||
public synchronized MenuSnapshot reload() {
|
||||
long ttlSeconds = resolveTtlSeconds();
|
||||
List<PortalMenuItem> items = menuDataService.loadAllItems();
|
||||
List<PortalMenuPlacement> placements = menuDataService.loadAllPlacements();
|
||||
|
||||
Map<String, PortalMenuItem> itemsById = items.stream()
|
||||
.collect(Collectors.toMap(PortalMenuItem::getMenuId, item -> item, (a, b) -> a, LinkedHashMap::new));
|
||||
|
||||
// 가시(visible=Y) 배치만 렌더 트리에 포함
|
||||
Map<String, List<PortalMenuPlacement>> byParent = placements.stream()
|
||||
.filter(PortalMenuPlacement::isVisible)
|
||||
.filter(placement -> itemsById.containsKey(placement.getMenuId()))
|
||||
.collect(Collectors.groupingBy(
|
||||
placement -> placement.getParentId() == null ? "" : placement.getParentId(),
|
||||
LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
List<MenuNode> roots = buildNodes(byParent.getOrDefault("", Collections.emptyList()), itemsById, byParent);
|
||||
|
||||
// 접근 권한 맵은 배치/노출과 무관하게 항목 기준으로 구성 (숨김 메뉴도 접근 통제 유지)
|
||||
Map<String, List<String>> accessRolesByPath = new LinkedHashMap<>();
|
||||
for (PortalMenuItem item : items) {
|
||||
if (item.getMenuPath() != null && item.getAccessRoles() != null
|
||||
&& !item.getAccessRoles().trim().isEmpty()) {
|
||||
accessRolesByPath.put(normalizePath(item.getMenuPath()), splitCsv(item.getAccessRoles()));
|
||||
}
|
||||
}
|
||||
|
||||
if (roots.isEmpty()) {
|
||||
ttlSeconds = Math.min(ttlSeconds, EMPTY_RETRY_TTL_SECONDS);
|
||||
}
|
||||
MenuSnapshot rebuilt = new MenuSnapshot(roots, accessRolesByPath, items.size(),
|
||||
System.currentTimeMillis(), ttlSeconds);
|
||||
snapshot = rebuilt;
|
||||
log.info("메뉴 캐시 갱신 - 항목 {}건, 최상위 {}건, TTL {}초", items.size(), roots.size(), ttlSeconds);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
private List<MenuNode> buildNodes(List<PortalMenuPlacement> placements,
|
||||
Map<String, PortalMenuItem> itemsById,
|
||||
Map<String, List<PortalMenuPlacement>> byParent) {
|
||||
List<MenuNode> nodes = new ArrayList<>();
|
||||
for (PortalMenuPlacement placement : placements) {
|
||||
PortalMenuItem item = itemsById.get(placement.getMenuId());
|
||||
MenuNode node = toNode(item);
|
||||
node.setChildren(buildNodes(
|
||||
byParent.getOrDefault(item.getMenuId(), Collections.emptyList()), itemsById, byParent));
|
||||
nodes.add(node);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private MenuNode toNode(PortalMenuItem item) {
|
||||
MenuNode node = new MenuNode();
|
||||
node.setMenuId(item.getMenuId());
|
||||
node.setName(item.getMenuName());
|
||||
node.setPath(normalizePath(item.getMenuPath()));
|
||||
node.setGroup(item.isGroup());
|
||||
node.setSection(item.getMenuSection());
|
||||
node.setIcon(item.getIconClass());
|
||||
node.setNewWindow("Y".equals(item.getNewWindowYn()));
|
||||
node.setExposeRoles(splitCsv(item.getExposeRoles()));
|
||||
node.setAccessRoles(splitCsv(item.getAccessRoles()));
|
||||
return node;
|
||||
}
|
||||
|
||||
private long resolveTtlSeconds() {
|
||||
try {
|
||||
String value = portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_CACHE_TTL,
|
||||
DEFAULT_CACHE_TTL, "포탈 메뉴 캐시 TTL(초)");
|
||||
return Long.parseLong(value.trim());
|
||||
} catch (Exception e) {
|
||||
log.warn("메뉴 캐시 TTL 조회 실패 - 기본값 {}초 사용", DEFAULT_CACHE_TTL, e);
|
||||
return Long.parseLong(DEFAULT_CACHE_TTL);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> splitCsv(String csv) {
|
||||
if (csv == null || csv.trim().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return Arrays.stream(csv.split(","))
|
||||
.map(String::trim)
|
||||
.filter(token -> !token.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static String normalizePath(String path) {
|
||||
if (path == null || path.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = path.trim();
|
||||
int queryIndex = normalized.indexOf('?');
|
||||
return queryIndex >= 0 ? normalized.substring(0, queryIndex) : normalized;
|
||||
}
|
||||
|
||||
/** role 비의존 메뉴 스냅샷 (불변 취급) */
|
||||
@Getter
|
||||
public static class MenuSnapshot {
|
||||
private final List<MenuNode> roots;
|
||||
private final Map<String, List<String>> accessRolesByPath;
|
||||
private final int itemCount;
|
||||
private final long loadedAt;
|
||||
private final long ttlSeconds;
|
||||
|
||||
MenuSnapshot(List<MenuNode> roots, Map<String, List<String>> accessRolesByPath,
|
||||
int itemCount, long loadedAt, long ttlSeconds) {
|
||||
this.roots = roots;
|
||||
this.accessRolesByPath = accessRolesByPath;
|
||||
this.itemCount = itemCount;
|
||||
this.loadedAt = loadedAt;
|
||||
this.ttlSeconds = ttlSeconds;
|
||||
}
|
||||
|
||||
boolean isExpired() {
|
||||
return System.currentTimeMillis() - loadedAt > ttlSeconds * 1000L;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 요청별(role 필터 적용) 메뉴 뷰 — 템플릿 모델 {@code menuView}.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #getGnb()} — 상단 글로벌 네비(GNB 섹션 최상위)</li>
|
||||
* <li>{@link #getMypage()} — 마이페이지 드롭다운 루트 (미노출 시 null)</li>
|
||||
* <li>{@link #activeGroup(String)} — service_sidebar 용 활성 그룹 탐색</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Getter
|
||||
public class MenuView {
|
||||
|
||||
/**
|
||||
* service_sidebar 기존 호출부의 activeMenu 키 → 메뉴 ID 브리지.
|
||||
* 호출 페이지 수정 없이 DB 메뉴 기반 사이드바로 전환하기 위한 레거시 맵.
|
||||
*/
|
||||
private static final Map<String, String> LEGACY_ACTIVE_KEYS = new HashMap<>();
|
||||
|
||||
static {
|
||||
LEGACY_ACTIVE_KEYS.put("intro", "service-intro");
|
||||
LEGACY_ACTIVE_KEYS.put("guide", "service-guide");
|
||||
LEGACY_ACTIVE_KEYS.put("oauth2", "service-oauth2-guide");
|
||||
LEGACY_ACTIVE_KEYS.put("webhookGuide", "service-webhook-dev-guide");
|
||||
LEGACY_ACTIVE_KEYS.put("notice", "support-notice");
|
||||
LEGACY_ACTIVE_KEYS.put("faq", "support-faq");
|
||||
LEGACY_ACTIVE_KEYS.put("qna", "support-qna");
|
||||
LEGACY_ACTIVE_KEYS.put("feedback", "support-partnership");
|
||||
LEGACY_ACTIVE_KEYS.put("users", "my-page-users");
|
||||
LEGACY_ACTIVE_KEYS.put("apiKey", "my-page-clients");
|
||||
LEGACY_ACTIVE_KEYS.put("webhook", "my-page-webhook");
|
||||
LEGACY_ACTIVE_KEYS.put("statistics", "my-page-statistics");
|
||||
LEGACY_ACTIVE_KEYS.put("profile", "my-page-profile");
|
||||
LEGACY_ACTIVE_KEYS.put("password", "my-page-password");
|
||||
}
|
||||
|
||||
private final List<MenuNode> gnb;
|
||||
private final MenuNode mypage;
|
||||
private final String currentPath;
|
||||
|
||||
public MenuView(List<MenuNode> gnb, MenuNode mypage, String currentPath) {
|
||||
this.gnb = gnb;
|
||||
this.mypage = mypage;
|
||||
this.currentPath = currentPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드바용 활성 그룹. legacy activeMenu 키 → 메뉴 ID 우선,
|
||||
* 실패 시 현재 경로(정확/prefix) 매칭으로 폴백.
|
||||
*/
|
||||
public MenuNode activeGroup(String activeMenu) {
|
||||
String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu);
|
||||
MenuNode byId = targetId == null ? null : findRootContaining(node -> targetId.equals(node.getMenuId()));
|
||||
if (byId != null) {
|
||||
return byId;
|
||||
}
|
||||
return findRootContaining(this::matchesCurrentPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드바 항목 활성 여부: legacy 키 매칭 우선, 경로 매칭 폴백.
|
||||
*/
|
||||
public boolean isActive(MenuNode node, String activeMenu) {
|
||||
String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu);
|
||||
if (targetId != null) {
|
||||
return targetId.equals(node.getMenuId());
|
||||
}
|
||||
return matchesCurrentPath(node);
|
||||
}
|
||||
|
||||
private MenuNode findRootContaining(java.util.function.Predicate<MenuNode> predicate) {
|
||||
for (MenuNode root : allRoots()) {
|
||||
if (predicate.test(root) || root.getChildren().stream().anyMatch(predicate)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<MenuNode> allRoots() {
|
||||
if (mypage == null) {
|
||||
return gnb;
|
||||
}
|
||||
java.util.ArrayList<MenuNode> roots = new java.util.ArrayList<>(gnb);
|
||||
roots.add(mypage);
|
||||
return roots;
|
||||
}
|
||||
|
||||
private boolean matchesCurrentPath(MenuNode node) {
|
||||
if (!node.hasPath() || currentPath == null) {
|
||||
return false;
|
||||
}
|
||||
return currentPath.equals(node.getPath())
|
||||
|| currentPath.startsWith(node.getPath() + "/");
|
||||
}
|
||||
|
||||
public static MenuView empty() {
|
||||
return new MenuView(Collections.emptyList(), null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* menu.yml 바인딩 (spring.config.import 로 로드).
|
||||
* 트리(children 중첩) 형태 그대로 바인딩하며, 평탄화는 {@link MenuSeeder} 가 수행한다.
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "portal-menu")
|
||||
public class PortalMenuYmlProperties {
|
||||
|
||||
private List<MenuItemDef> items = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class MenuItemDef {
|
||||
/** kebab-case 자연키 (^[a-z0-9-]+$) */
|
||||
private String id;
|
||||
private String name;
|
||||
private String path;
|
||||
/** true = 클릭 없는 상위 그룹 */
|
||||
private boolean group;
|
||||
/** GNB(기본) | MYPAGE */
|
||||
private String section;
|
||||
/** FontAwesome 아이콘 클래스 (마이페이지 드롭다운) */
|
||||
private String icon;
|
||||
private boolean newWindow;
|
||||
private List<String> exposeRoles = new ArrayList<>();
|
||||
private List<String> accessRoles = new ArrayList<>();
|
||||
private List<MenuItemDef> children = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* roles.yml 바인딩 (spring.config.import 로 로드).
|
||||
* 기존 application.yml 의 {@code portal.portal_security} 를 대체한다.
|
||||
* 로그인 시 권한 확장(PortalUserAuthService)이 직접 사용하며,
|
||||
* DB 미러(PTL_ROLE / PTL_ROLE_AUTHORITY)는 {@link MenuSeeder} 가 적재한다.
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "portal-roles")
|
||||
public class PortalRolesProperties {
|
||||
|
||||
private List<RoleDef> roles = new ArrayList<>();
|
||||
|
||||
/** AUTHORITY 유형 역할 코드 → 한글 라벨 (admin UI 표기용) */
|
||||
private Map<String, String> authorityNames = new LinkedHashMap<>();
|
||||
|
||||
@Data
|
||||
public static class RoleDef {
|
||||
private String code;
|
||||
private String name;
|
||||
private List<String> authorities = new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 역할의 파생 권한 목록. 미정의 역할이면 빈 목록.
|
||||
*/
|
||||
public List<String> getAuthorities(RoleCode roleCode) {
|
||||
return roles.stream()
|
||||
.filter(role -> role.getCode().equals(roleCode.name()))
|
||||
.findFirst()
|
||||
.map(RoleDef::getAuthorities)
|
||||
.orElse(Collections.emptyList());
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ server:
|
||||
servlet:
|
||||
context-path: /
|
||||
session:
|
||||
# 세션 타임아웃 10분 고정 (DB property 관리 폐지).
|
||||
# 세션 타임아웃 10분 고정
|
||||
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
|
||||
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
|
||||
timeout: 10m
|
||||
@@ -23,6 +23,10 @@ server:
|
||||
|
||||
|
||||
spring:
|
||||
config:
|
||||
import:
|
||||
- classpath:menu.yml
|
||||
- classpath:roles.yml
|
||||
data:
|
||||
web:
|
||||
pageable:
|
||||
@@ -218,26 +222,7 @@ portal:
|
||||
view-name: apps/apis/static/tokenApiSpec
|
||||
bean: apiHandler
|
||||
|
||||
portal_security:
|
||||
ROLE_USER:
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_ACCOUNT
|
||||
ROLE_CORP_USER:
|
||||
- ROLE_API_KEY_REQUEST
|
||||
- ROLE_API_KEY_REQUEST_VIEW
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_APP
|
||||
- ROLE_ACCOUNT
|
||||
ROLE_CORP_MANAGER:
|
||||
- ROLE_API_KEY_REQUEST
|
||||
- ROLE_API_KEY_REQUEST_VIEW
|
||||
- ROLE_WEBHOOK
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_APP
|
||||
- ROLE_ACCOUNT
|
||||
- ROLE_CORP_API
|
||||
- ROLE_DASHBOARD
|
||||
- ROLE_USER_MANAGER
|
||||
# portal_security 는 roles.yml (portal-roles) 로 이동
|
||||
page:
|
||||
home:
|
||||
name: "Home"
|
||||
@@ -318,6 +303,13 @@ page:
|
||||
api_testbed:
|
||||
name: "테스트베드"
|
||||
path: "/apis/detail/testbed"
|
||||
apistatus:
|
||||
name: "API Status"
|
||||
path: "/apistatus"
|
||||
children:
|
||||
issues:
|
||||
name: "전체 이슈 이력"
|
||||
path: "/apistatus/issues"
|
||||
community:
|
||||
name: 고객지원
|
||||
path: "#"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# 포탈 GNB 메뉴 정의 (menu.yml)
|
||||
#
|
||||
# - 부팅 시 PTL_MENU_ITEM 으로 자동 적재(upsert by id). 동일 id 의 내용이 바뀌면
|
||||
# 기본값(DFLT_*)이 갱신되고, 관리자가 수정하지 않은 필드만 새 기본값을 따라간다.
|
||||
# - 배치(위치) 정보는 PTL_MENU_PLACEMENT 가 비어있을 때만 최초 적재된다.
|
||||
# 이후 배치 관리는 eapim-admin "포탈메뉴관리" 화면에서 수행.
|
||||
# - id: kebab-case (^[a-z0-9-]+$). 변경 시 새 항목으로 인식되므로 변경 금지.
|
||||
# - group: true → 상위 그룹(클릭 없음). path 를 주면 그룹도 링크 동작.
|
||||
# - section: GNB(기본) | MYPAGE(마이페이지 드롭다운)
|
||||
# - expose-roles / access-roles: 생략=전체 허용, AUTHENTICATED=로그인 사용자,
|
||||
# 그 외 역할 코드 나열 시 하나라도 보유하면 허용(any-of)
|
||||
# ------------------------------------------------------------------------------
|
||||
portal-menu:
|
||||
items:
|
||||
- id: service
|
||||
name: "서비스 소개"
|
||||
group: true
|
||||
children:
|
||||
- { id: service-intro, name: "API 포탈 소개", path: /service/intro }
|
||||
- { id: service-guide, name: "회원가입 안내", path: /service/guide }
|
||||
- { id: service-oauth2-guide, name: "OAuth2 개발가이드", path: /service/oauth2-guide }
|
||||
- { id: service-webhook-dev-guide, name: "웹훅 개발가이드", path: /service/webhook-dev-guide }
|
||||
|
||||
- { id: open-api, name: "오픈 API", path: /apis }
|
||||
|
||||
- id: support
|
||||
name: "고객지원"
|
||||
group: true
|
||||
children:
|
||||
- { id: support-notice, name: "공지사항", path: /portalnotice }
|
||||
- { id: support-faq, name: "FAQ", path: /faq_list }
|
||||
- { id: support-qna, name: "Q&A", path: /inquiry }
|
||||
- { id: support-partnership, name: "피드백/개선요청", path: /partnership }
|
||||
|
||||
- { id: api-status, name: "API Status", path: /apistatus }
|
||||
|
||||
- id: my-page
|
||||
name: "마이페이지"
|
||||
group: true
|
||||
section: MYPAGE
|
||||
expose-roles: [AUTHENTICATED]
|
||||
children:
|
||||
- { id: my-page-users, name: "개발자 관리", path: /users, icon: fa-users,
|
||||
expose-roles: [ROLE_CORP_MANAGER], access-roles: [ROLE_CORP_MANAGER] }
|
||||
- { id: my-page-clients, name: "API 신청 관리", path: /clients, icon: fa-key,
|
||||
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
|
||||
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
|
||||
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
|
||||
- { id: my-page-statistics, name: "이용 통계", path: /statistics/api, icon: fa-chart-bar,
|
||||
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
|
||||
- { id: my-page-profile, name: "내 정보 관리", path: /mypage, icon: fa-user-circle,
|
||||
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
|
||||
- { id: my-page-password, name: "비밀번호 변경", path: /password/change, icon: fa-lock,
|
||||
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
|
||||
@@ -0,0 +1,47 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# 포탈 역할 정의 (roles.yml)
|
||||
#
|
||||
# - 기존 application.yml 의 portal.portal_security 를 이동한 파일.
|
||||
# - 로그인 시 권한 확장(PortalUserAuthService)은 이 파일 바인딩을 직접 사용한다.
|
||||
# - 부팅 시 PTL_ROLE / PTL_ROLE_AUTHORITY 로 미러 적재되며,
|
||||
# 해당 테이블은 eapim-admin 메뉴 권한 선택 UI 소스로만 소비된다.
|
||||
# ------------------------------------------------------------------------------
|
||||
portal-roles:
|
||||
roles:
|
||||
- code: ROLE_USER
|
||||
name: "개인사용자"
|
||||
authorities:
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_ACCOUNT
|
||||
- code: ROLE_CORP_USER
|
||||
name: "법인사용자"
|
||||
authorities:
|
||||
- ROLE_API_KEY_REQUEST
|
||||
- ROLE_API_KEY_REQUEST_VIEW
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_APP
|
||||
- ROLE_ACCOUNT
|
||||
- code: ROLE_CORP_MANAGER
|
||||
name: "법인관리자"
|
||||
authorities:
|
||||
- ROLE_API_KEY_REQUEST
|
||||
- ROLE_API_KEY_REQUEST_VIEW
|
||||
- ROLE_WEBHOOK
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_APP
|
||||
- ROLE_ACCOUNT
|
||||
- ROLE_CORP_API
|
||||
- ROLE_DASHBOARD
|
||||
- ROLE_USER_MANAGER
|
||||
|
||||
# PTL_ROLE(AUTHORITY 유형) 한글 라벨 — admin 권한 선택 UI 표기용
|
||||
authority-names:
|
||||
ROLE_INQUIRY: "문의 작성"
|
||||
ROLE_ACCOUNT: "계정 관리"
|
||||
ROLE_API_KEY_REQUEST: "인증키 신청"
|
||||
ROLE_API_KEY_REQUEST_VIEW: "인증키 신청 조회"
|
||||
ROLE_APP: "앱/API 신청 관리"
|
||||
ROLE_WEBHOOK: "웹훅 관리"
|
||||
ROLE_CORP_API: "법인 API 조회"
|
||||
ROLE_DASHBOARD: "대시보드"
|
||||
ROLE_USER_MANAGER: "개발자 관리"
|
||||
+1691
-907
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,498 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const page = document.getElementById('issueHistoryPage');
|
||||
if (!page) return;
|
||||
|
||||
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
|
||||
const base = page.getAttribute('data-base') || '/apistatus';
|
||||
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
||||
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
|
||||
|
||||
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
||||
const linkableApiIds = new Set();
|
||||
|
||||
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
|
||||
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
|
||||
const PAGE_SIZE = 10;
|
||||
const API_LIST_LIMIT = 50; // 라이브서치 결과 표시 상한
|
||||
|
||||
const today = page.getAttribute('data-today') || '';
|
||||
// My APIs 등에서 넘어올 때 표시용 API 명 (필터 자체는 apiId 로 동작)
|
||||
const urlApiName = new URLSearchParams(window.location.search).get('apiName') || '';
|
||||
const minDate = page.getAttribute('data-min-date') || '';
|
||||
|
||||
// 진입 시 날짜 미지정 = 조회 기간 전체
|
||||
const state = {
|
||||
date: page.getAttribute('data-selected-date') || '',
|
||||
apiId: page.getAttribute('data-selected-api') || '',
|
||||
kind: page.getAttribute('data-selected-kind') || '',
|
||||
page: 0
|
||||
};
|
||||
|
||||
const indexBar = document.getElementById('issueIndexBar');
|
||||
const listEl = document.getElementById('issueList');
|
||||
const pagerEl = document.getElementById('issuePager');
|
||||
const totalCountEl = document.getElementById('issueTotalCount');
|
||||
const selectedDateLabel = document.getElementById('selectedDateLabel');
|
||||
const dateField = document.getElementById('dateField');
|
||||
const dateInput = document.getElementById('filterDateInput');
|
||||
const dateClearBtn = document.getElementById('filterDateClear');
|
||||
const kindSelect = document.getElementById('filterKindSelect');
|
||||
const apiCombo = document.getElementById('apiCombo');
|
||||
const apiInput = document.getElementById('filterApiInput');
|
||||
const apiClearBtn = document.getElementById('filterApiClear');
|
||||
const apiListEl = document.getElementById('filterApiList');
|
||||
const unlistedBanner = document.getElementById('unlistedApiBanner');
|
||||
const unlistedName = document.getElementById('unlistedApiName');
|
||||
|
||||
let apiOptions = [];
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text == null) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function fetchJson(url) {
|
||||
return fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
function parseDateTime(value) {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(String(value).replace(' ', 'T'));
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return value < 10 ? '0' + value : String(value);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = parseDateTime(value);
|
||||
if (!date) return '-';
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const date = parseDateTime(value);
|
||||
if (!date) return '-';
|
||||
return formatDate(value) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
|
||||
}
|
||||
|
||||
function formatDuration(minutes) {
|
||||
if (minutes == null) return '';
|
||||
if (minutes < 60) return minutes + '분';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return rest === 0 ? hours + '시간' : hours + '시간 ' + rest + '분';
|
||||
}
|
||||
|
||||
function buildQuery(params) {
|
||||
const query = [];
|
||||
Object.keys(params).forEach(function (key) {
|
||||
if (params[key] !== '' && params[key] != null) {
|
||||
query.push(key + '=' + encodeURIComponent(params[key]));
|
||||
}
|
||||
});
|
||||
return query.length ? '?' + query.join('&') : '';
|
||||
}
|
||||
|
||||
function syncBrowserUrl() {
|
||||
if (!window.history || !window.history.replaceState) return;
|
||||
window.history.replaceState(null, '',
|
||||
base + '/issues' + buildQuery({ date: state.date, apiId: state.apiId, kind: state.kind }));
|
||||
}
|
||||
|
||||
/** 필터 변경 후 목록/인덱스 재조회 */
|
||||
function applyFilters(reloadIndex) {
|
||||
state.page = 0;
|
||||
syncBrowserUrl();
|
||||
renderIndexBarSelection();
|
||||
if (reloadIndex) loadIndexBar();
|
||||
loadIssues();
|
||||
}
|
||||
|
||||
// ---------------- 90일 인덱스바 ----------------
|
||||
function renderIndexBar(entries) {
|
||||
if (!entries || !entries.length) {
|
||||
indexBar.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
indexBar.innerHTML = entries.map(function (entry) {
|
||||
const hasIncident = entry.incCount > 0;
|
||||
const hasDelay = entry.dlyCount > 0;
|
||||
const hasMaintenance = entry.mntCount > 0;
|
||||
|
||||
// 유형이 2종 이상 섞인 날은 개별 색 대신 복합 색으로 표시한다
|
||||
const kindCount = (hasIncident ? 1 : 0) + (hasDelay ? 1 : 0) + (hasMaintenance ? 1 : 0);
|
||||
let cls = '';
|
||||
if (kindCount > 1) cls = ' has-mixed';
|
||||
else if (hasIncident) cls = ' has-incident';
|
||||
else if (hasDelay) cls = ' has-degraded';
|
||||
else if (hasMaintenance) cls = ' has-maintenance';
|
||||
if (state.date && state.date === entry.date) cls += ' is-selected';
|
||||
|
||||
const parts = [];
|
||||
if (hasIncident) parts.push('장애 ' + entry.incCount + '건');
|
||||
if (hasDelay) parts.push('지연 ' + entry.dlyCount + '건');
|
||||
if (hasMaintenance) parts.push('점검 ' + entry.mntCount + '건');
|
||||
const tooltip = entry.date + (parts.length ? ' · ' + parts.join(' · ') : ' · 이슈 없음');
|
||||
|
||||
return '<button type="button" class="as-index-cell' + cls + '"'
|
||||
+ ' data-date="' + escapeHtml(entry.date) + '"'
|
||||
+ ' title="' + escapeHtml(tooltip) + '"></button>';
|
||||
}).join('');
|
||||
|
||||
indexBar.querySelectorAll('.as-index-cell').forEach(function (cell) {
|
||||
cell.addEventListener('click', function () {
|
||||
const clicked = cell.getAttribute('data-date');
|
||||
state.date = state.date === clicked ? '' : clicked;
|
||||
applyFilters(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderIndexBarSelection() {
|
||||
indexBar.querySelectorAll('.as-index-cell').forEach(function (cell) {
|
||||
cell.classList.toggle('is-selected', state.date === cell.getAttribute('data-date'));
|
||||
});
|
||||
selectedDateLabel.textContent = state.date || '전체 기간';
|
||||
if (dateInput.value !== state.date) {
|
||||
dateInput.value = state.date;
|
||||
}
|
||||
dateField.classList.toggle('has-value', !!state.date);
|
||||
}
|
||||
|
||||
// ---------------- 이슈 카드 ----------------
|
||||
/**
|
||||
* 영향 API 태그.
|
||||
*
|
||||
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
|
||||
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
|
||||
*/
|
||||
function apiPillsHtml(apis, hiddenCount) {
|
||||
const hidden = hiddenCount || 0;
|
||||
if ((!apis || !apis.length) && hidden === 0) return '';
|
||||
|
||||
const pills = (apis || []).map(function (api) {
|
||||
const text = escapeHtml(api.apiName || api.apiId);
|
||||
if (!linkableApiIds.has(api.apiId)) {
|
||||
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
|
||||
}
|
||||
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
||||
+ text + '</a>';
|
||||
}).join('');
|
||||
|
||||
// 개발자포탈에 게시되지 않은 GW 인터페이스는 개별 노출 없이 건수로만 알린다
|
||||
const hiddenPill = hidden > 0
|
||||
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
|
||||
+ 'GW 인터페이스 ' + hidden + '건</span>'
|
||||
: '';
|
||||
|
||||
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + hiddenPill + '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
|
||||
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
|
||||
*/
|
||||
function noticeHtml(issue) {
|
||||
if (!issue.noticeDetail) return '';
|
||||
const link = issue.noticeId
|
||||
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
|
||||
+ encodeURIComponent(issue.noticeId) + '">공지 전체 보기 →</a>'
|
||||
: '';
|
||||
return '<div class="as-alert-notice">'
|
||||
+ '<div class="as-alert-notice-head">'
|
||||
+ '<span class="as-alert-notice-label">공지사항</span>'
|
||||
+ (issue.noticeSubject ? '<strong>' + escapeHtml(issue.noticeSubject) + '</strong>' : '')
|
||||
+ '</div>'
|
||||
+ '<div class="as-alert-notice-body editor-content">' + issue.noticeDetail + '</div>'
|
||||
+ link
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
|
||||
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
|
||||
*/
|
||||
function splitHtml(left, right) {
|
||||
if (!left) return right;
|
||||
if (!right) return left;
|
||||
return '<div class="as-card-split">' + left + right + '</div>';
|
||||
}
|
||||
|
||||
function issueCardHtml(issue) {
|
||||
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
|
||||
|
||||
let inner;
|
||||
if (issue.kind === 'MAINTENANCE') {
|
||||
inner = '<p class="as-tl-body">' + escapeHtml(issue.summary || '점검 안내') + '</p>';
|
||||
} else {
|
||||
const items = (issue.timeline || []).map(function (entry) {
|
||||
return '<div class="as-tl-item state-' + escapeHtml(entry.stateAfter || 'NONE') + '">'
|
||||
+ '<span class="as-dot"></span>'
|
||||
+ '<p class="as-tl-label">' + escapeHtml(entry.labelKo || '진행 상황') + '</p>'
|
||||
+ '<p class="as-tl-body">' + escapeHtml(entry.body) + '</p>'
|
||||
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(entry.eventAt)) + '</p>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
inner = items
|
||||
? '<div class="as-tl-block">' + items + '</div>'
|
||||
: '<p class="as-tl-body">' + escapeHtml(issue.summary || '상세 진행 내역이 등록되지 않았습니다.') + '</p>';
|
||||
}
|
||||
|
||||
const period = formatDateTime(issue.startedAt)
|
||||
+ (issue.endAt ? ' ~ ' + formatDateTime(issue.endAt) : ' ~ 진행 중')
|
||||
+ (issue.durationMinutes != null ? ' (' + formatDuration(issue.durationMinutes) + ')' : '');
|
||||
|
||||
return '<article class="as-issue-card ' + kindClass + '">'
|
||||
+ '<div class="as-issue-meta-row">'
|
||||
+ ' <span class="as-badge ' + kindClass + '">' + escapeHtml(KIND_LABEL[issue.kind] || '') + '</span>'
|
||||
+ ' <span>' + escapeHtml(period) + '</span>'
|
||||
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
||||
+ splitHtml(inner, noticeHtml(issue))
|
||||
+ apiPillsHtml(issue.impactedApis, issue.hiddenApiCount)
|
||||
+ '</article>';
|
||||
}
|
||||
|
||||
function renderPager(pageData) {
|
||||
const totalPages = pageData.totalPages || 0;
|
||||
if (totalPages <= 1) {
|
||||
pagerEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (let index = 0; index < totalPages; index++) {
|
||||
const current = index === pageData.number;
|
||||
html += '<button type="button" class="as-btn' + (current ? ' is-current' : '') + '"'
|
||||
+ ' data-page="' + index + '"' + (current ? ' disabled' : '') + '>' + (index + 1) + '</button>';
|
||||
}
|
||||
pagerEl.innerHTML = html;
|
||||
pagerEl.querySelectorAll('button[data-page]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
state.page = parseInt(button.getAttribute('data-page'), 10) || 0;
|
||||
loadIssues();
|
||||
window.scrollTo({ top: listEl.offsetTop - 80, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadIssues() {
|
||||
const query = buildQuery({
|
||||
date: state.date,
|
||||
apiId: state.apiId,
|
||||
kind: state.kind,
|
||||
page: state.page,
|
||||
size: PAGE_SIZE
|
||||
});
|
||||
|
||||
fetchJson(base + '/issues/list.json' + query)
|
||||
.then(function (pageData) {
|
||||
const content = pageData.content || [];
|
||||
totalCountEl.textContent = pageData.totalElements != null ? pageData.totalElements : content.length;
|
||||
|
||||
listEl.innerHTML = content.length
|
||||
? content.map(issueCardHtml).join('')
|
||||
: '<div class="as-empty">조건에 해당하는 이슈가 없습니다.</div>';
|
||||
renderPager(pageData);
|
||||
})
|
||||
.catch(function () {
|
||||
listEl.innerHTML = '<div class="as-empty">이슈 목록을 불러올 수 없습니다.</div>';
|
||||
pagerEl.innerHTML = '';
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- API 필터 (라이브서치 콤보박스) ----------------
|
||||
|
||||
/** 옵션 = 오픈 API 목록과 동일 기준(그룹 편성 + 사용자 공개). 표시는 API 명만. */
|
||||
function loadApiOptions() {
|
||||
return fetchJson(base + '/apis.json')
|
||||
.then(function (apis) {
|
||||
apiOptions = (apis || []).filter(function (api) { return api.apiId; });
|
||||
apiOptions.forEach(function (api) { linkableApiIds.add(api.apiId); });
|
||||
// URL 로 들어온 apiId 는 옵션(오픈 API 목록)에 없어도 필터를 유지한다.
|
||||
// My APIs(기관 계약 API)처럼 오픈 API 목록 밖의 API 로도 진입하기 때문.
|
||||
renderApiInput();
|
||||
})
|
||||
.catch(function () { /* 옵션 조회 실패 시 전체 API 기준으로 동작 */ });
|
||||
}
|
||||
|
||||
function findApiOption(apiId) {
|
||||
if (!apiId) return null;
|
||||
for (let index = 0; index < apiOptions.length; index++) {
|
||||
if (apiOptions[index].apiId === apiId) return apiOptions[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 표시 형태: "[그룹명] API명" (그룹 없으면 API명만) */
|
||||
function optionLabel(api) {
|
||||
if (!api) return '';
|
||||
return api.groupName ? '[' + api.groupName + '] ' + api.apiName : api.apiName;
|
||||
}
|
||||
|
||||
function renderApiInput() {
|
||||
const selected = findApiOption(state.apiId);
|
||||
// 옵션에 없는 API(오픈 API 목록 밖)는 전달받은 API 명 또는 ID 로 표시해 필터 상태를 보이게 한다
|
||||
apiInput.value = selected ? optionLabel(selected) : (state.apiId ? (urlApiName || state.apiId) : '');
|
||||
apiCombo.classList.toggle('has-value', !!state.apiId);
|
||||
|
||||
// 포탈 미게시 API 필터 안내 배너 (옵션 목록을 받은 뒤에만 판단)
|
||||
const unlisted = !!state.apiId && apiOptions.length > 0 && !selected;
|
||||
if (unlisted) {
|
||||
unlistedName.textContent = urlApiName || state.apiId;
|
||||
}
|
||||
unlistedBanner.style.display = unlisted ? '' : 'none';
|
||||
}
|
||||
|
||||
function closeApiList() {
|
||||
apiListEl.hidden = true;
|
||||
apiInput.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function openApiList(keyword) {
|
||||
const query = (keyword || '').trim().toLowerCase();
|
||||
const matched = apiOptions.filter(function (api) {
|
||||
if (!query) return true;
|
||||
return optionLabel(api).toLowerCase().indexOf(query) >= 0;
|
||||
});
|
||||
|
||||
if (!matched.length) {
|
||||
apiListEl.innerHTML = '<li class="as-combo-empty">검색 결과가 없습니다</li>';
|
||||
} else {
|
||||
const items = matched.slice(0, API_LIST_LIMIT).map(function (api) {
|
||||
return '<li role="option" class="as-combo-item" data-api-id="' + escapeHtml(api.apiId) + '">'
|
||||
+ escapeHtml(optionLabel(api)) + '</li>';
|
||||
});
|
||||
if (matched.length > API_LIST_LIMIT) {
|
||||
items.push('<li class="as-combo-empty">이하 '
|
||||
+ (matched.length - API_LIST_LIMIT) + '건은 검색어를 입력해 좁혀주세요</li>');
|
||||
}
|
||||
apiListEl.innerHTML = '<li role="option" class="as-combo-item" data-api-id="">전체 API</li>'
|
||||
+ items.join('');
|
||||
}
|
||||
|
||||
apiListEl.hidden = false;
|
||||
apiInput.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
function selectApi(apiId) {
|
||||
state.apiId = apiId || '';
|
||||
renderApiInput();
|
||||
closeApiList();
|
||||
applyFilters(true);
|
||||
}
|
||||
|
||||
function loadIndexBar() {
|
||||
fetchJson(base + '/issues/dates.json'
|
||||
+ buildQuery({ days: windowDays, apiId: state.apiId, kind: state.kind }))
|
||||
.then(function (entries) {
|
||||
renderIndexBar(entries);
|
||||
renderIndexBarSelection();
|
||||
})
|
||||
.catch(function () { indexBar.innerHTML = ''; });
|
||||
}
|
||||
|
||||
// ---------------- 필터 이벤트 ----------------
|
||||
|
||||
dateInput.addEventListener('change', function () {
|
||||
const value = dateInput.value;
|
||||
// 조회 기간(90일) 밖 날짜는 되돌린다
|
||||
if (value && ((minDate && value < minDate) || (today && value > today))) {
|
||||
dateInput.value = state.date;
|
||||
return;
|
||||
}
|
||||
state.date = value;
|
||||
applyFilters(false);
|
||||
});
|
||||
|
||||
dateClearBtn.addEventListener('click', function () {
|
||||
state.date = '';
|
||||
dateInput.value = '';
|
||||
applyFilters(false);
|
||||
});
|
||||
|
||||
kindSelect.addEventListener('change', function () {
|
||||
state.kind = kindSelect.value;
|
||||
applyFilters(true);
|
||||
});
|
||||
|
||||
// readonly 자동완성 차단 트릭: 브라우저 autofill 은 readonly 입력을 후보에서 제외한다.
|
||||
// 사용자가 실제로 만지는 순간에만 readonly 를 풀어 입력을 허용한다 (모바일은 touchstart 가 먼저 온다).
|
||||
function unlockApiInput() {
|
||||
if (apiInput.hasAttribute('readonly')) {
|
||||
apiInput.removeAttribute('readonly');
|
||||
}
|
||||
}
|
||||
apiInput.addEventListener('touchstart', unlockApiInput, { passive: true });
|
||||
apiInput.addEventListener('mousedown', unlockApiInput);
|
||||
|
||||
apiInput.addEventListener('focus', function () {
|
||||
unlockApiInput();
|
||||
openApiList('');
|
||||
});
|
||||
|
||||
apiInput.addEventListener('input', function () { openApiList(apiInput.value); });
|
||||
|
||||
apiInput.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') {
|
||||
renderApiInput();
|
||||
closeApiList();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const first = apiListEl.querySelector('.as-combo-item');
|
||||
if (first) selectApi(first.getAttribute('data-api-id'));
|
||||
}
|
||||
});
|
||||
|
||||
apiListEl.addEventListener('mousedown', function (event) {
|
||||
const item = event.target.closest('.as-combo-item');
|
||||
if (!item) return;
|
||||
event.preventDefault();
|
||||
selectApi(item.getAttribute('data-api-id'));
|
||||
});
|
||||
|
||||
apiClearBtn.addEventListener('click', function () {
|
||||
apiInput.value = '';
|
||||
selectApi('');
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (!apiCombo.contains(event.target)) {
|
||||
renderApiInput();
|
||||
closeApiList();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('filterResetBtn').addEventListener('click', function () {
|
||||
state.date = '';
|
||||
state.apiId = '';
|
||||
state.kind = '';
|
||||
dateInput.value = '';
|
||||
kindSelect.value = '';
|
||||
renderApiInput();
|
||||
closeApiList();
|
||||
applyFilters(true);
|
||||
});
|
||||
|
||||
// ---------------- 초기 로딩 ----------------
|
||||
dateInput.value = state.date;
|
||||
kindSelect.value = state.kind;
|
||||
renderIndexBarSelection();
|
||||
loadIndexBar();
|
||||
// 영향 API 태그의 링크 여부 판단에 오픈 API 목록이 필요하므로 옵션을 먼저 받는다
|
||||
loadApiOptions().then(loadIssues);
|
||||
})();
|
||||
@@ -0,0 +1,399 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const page = document.getElementById('apiStatusPage');
|
||||
if (!page) return;
|
||||
|
||||
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
|
||||
const authenticated = page.classList.contains('is-authenticated');
|
||||
const base = page.getAttribute('data-base') || '/apistatus';
|
||||
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
||||
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
|
||||
|
||||
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
||||
const linkableApiIds = new Set();
|
||||
|
||||
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
|
||||
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
|
||||
|
||||
/** 진행 중 장애 카드 노출 상한. 초과분은 전체 이력 링크로 넘긴다. */
|
||||
const ACTIVE_INCIDENT_LIMIT = 1;
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text == null) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function fetchJson(url) {
|
||||
return fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
/** "2026-07-30T09:15:00" → Date. 서버는 서버 timezone(KST) 기준 LocalDateTime 을 보낸다. */
|
||||
function parseDateTime(value) {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(String(value).replace(' ', 'T'));
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return value < 10 ? '0' + value : String(value);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = parseDateTime(value);
|
||||
if (!date) return '-';
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const date = parseDateTime(value);
|
||||
if (!date) return '-';
|
||||
return formatDate(value) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
const date = parseDateTime(value);
|
||||
if (!date) return '-';
|
||||
return pad(date.getHours()) + ':' + pad(date.getMinutes());
|
||||
}
|
||||
|
||||
function formatDuration(minutes) {
|
||||
if (minutes == null) return '';
|
||||
if (minutes < 60) return minutes + '분';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return rest === 0 ? hours + '시간' : hours + '시간 ' + rest + '분';
|
||||
}
|
||||
|
||||
function formatPercent(ratio) {
|
||||
if (ratio == null) return '-';
|
||||
return (ratio * 100).toFixed(2);
|
||||
}
|
||||
|
||||
function issuesHref(params) {
|
||||
const query = [];
|
||||
if (params.date) query.push('date=' + encodeURIComponent(params.date));
|
||||
if (params.apiId) query.push('apiId=' + encodeURIComponent(params.apiId));
|
||||
if (params.apiName) query.push('apiName=' + encodeURIComponent(params.apiName));
|
||||
return base + '/issues' + (query.length ? '?' + query.join('&') : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 영향 API 태그.
|
||||
*
|
||||
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
|
||||
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
|
||||
*/
|
||||
function apiPillsHtml(apis, label, hiddenCount) {
|
||||
const hidden = hiddenCount || 0;
|
||||
if ((!apis || !apis.length) && hidden === 0) return '';
|
||||
|
||||
const pills = (apis || []).map(function (api) {
|
||||
const text = escapeHtml(api.apiName || api.apiId);
|
||||
if (!linkableApiIds.has(api.apiId)) {
|
||||
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
|
||||
}
|
||||
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
||||
+ text + '</a>';
|
||||
}).join('');
|
||||
|
||||
const hiddenPill = hidden > 0
|
||||
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
|
||||
+ 'GW 인터페이스 ' + hidden + '건</span>'
|
||||
: '';
|
||||
|
||||
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + hiddenPill + '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
|
||||
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
|
||||
*/
|
||||
function noticeHtml(incident) {
|
||||
if (!incident.noticeDetail) return '';
|
||||
const link = incident.noticeId
|
||||
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
|
||||
+ encodeURIComponent(incident.noticeId) + '">공지 전체 보기 →</a>'
|
||||
: '';
|
||||
return '<div class="as-alert-notice">'
|
||||
+ '<div class="as-alert-notice-head">'
|
||||
+ '<span class="as-alert-notice-label">공지사항</span>'
|
||||
+ (incident.noticeSubject ? '<strong>' + escapeHtml(incident.noticeSubject) + '</strong>' : '')
|
||||
+ '</div>'
|
||||
+ '<div class="as-alert-notice-body editor-content">' + incident.noticeDetail + '</div>'
|
||||
+ link
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
|
||||
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
|
||||
*/
|
||||
function splitHtml(left, right) {
|
||||
if (!left) return right;
|
||||
if (!right) return left;
|
||||
return '<div class="as-card-split">' + left + right + '</div>';
|
||||
}
|
||||
|
||||
// ---------------- ❶ 진행 중 장애 ----------------
|
||||
function renderActiveIncidents(incidents) {
|
||||
const container = document.getElementById('activeIncidents');
|
||||
if (!incidents || !incidents.length) {
|
||||
container.innerHTML = '<section class="as-card">'
|
||||
+ '<div class="as-card-head"><div><h2>현재 진행 중인 장애가 없습니다</h2>'
|
||||
+ '<p class="as-desc">모든 API 가 정상 동작 중입니다.</p></div>'
|
||||
+ '<span class="as-badge is-normal">정상</span></div></section>';
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = incidents.slice(0, ACTIVE_INCIDENT_LIMIT);
|
||||
const hidden = incidents.length - visible.length;
|
||||
|
||||
const cards = visible.map(function (incident) {
|
||||
const timeline = (incident.recentTimeline || []).map(function (entry) {
|
||||
const who = entry.authorType === 'SYSTEM' ? 'SYSTEM' : '운영자';
|
||||
const whoClass = entry.authorType === 'SYSTEM' ? ' is-system' : '';
|
||||
return '<div class="as-tl-row">'
|
||||
+ '<span class="as-tl-time">' + escapeHtml(formatTime(entry.eventAt)) + '</span>'
|
||||
+ '<span class="as-tl-who' + whoClass + '">' + escapeHtml(who) + '</span>'
|
||||
+ '<span>' + escapeHtml(entry.body) + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
return '<section class="as-card as-alert-card">'
|
||||
+ '<div class="as-alert-head">'
|
||||
+ ' <div><span class="as-badge is-incident">장애</span>'
|
||||
+ (incident.stateLabel ? ' <span class="as-badge is-muted">' + escapeHtml(incident.stateLabel) + '</span>' : '')
|
||||
+ ' </div>'
|
||||
+ ' <div class="as-meta">시작 <strong>' + escapeHtml(formatDateTime(incident.startedAt)) + '</strong>'
|
||||
+ ' · 경과 <strong>' + escapeHtml(formatDuration(incident.elapsedMinutes)) + '</strong></div>'
|
||||
+ '</div>'
|
||||
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
|
||||
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
|
||||
+ apiPillsHtml(incident.apis, '영향 API:', incident.hiddenApiCount)
|
||||
+ splitHtml(timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '',
|
||||
noticeHtml(incident))
|
||||
+ '</section>';
|
||||
}).join('');
|
||||
|
||||
const overflow = hidden > 0
|
||||
? '<div class="as-alert-overflow">'
|
||||
+ '<span>진행 중인 장애가 ' + escapeHtml(String(hidden)) + '건 더 있습니다.</span>'
|
||||
+ '<a href="' + issuesHref({}) + '">전체 이슈 이력에서 보기 →</a>'
|
||||
+ '</div>'
|
||||
: '';
|
||||
|
||||
container.innerHTML = cards + overflow;
|
||||
}
|
||||
|
||||
// ---------------- ❷ 90일 서비스 상태 ----------------
|
||||
function renderUptime(stats) {
|
||||
const chart = document.getElementById('uptimeChart');
|
||||
const fromLabel = document.getElementById('uptimeFromLabel');
|
||||
|
||||
if (!stats || !stats.length) {
|
||||
chart.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { OUTAGE: '장애', MAINTENANCE: '점검', DEGRADED: '지연' };
|
||||
|
||||
chart.innerHTML = stats.map(function (stat) {
|
||||
const statusClass = stat.status === 'OUTAGE' ? ' is-outage'
|
||||
: stat.status === 'MAINTENANCE' ? ' is-maintenance'
|
||||
: stat.status === 'DEGRADED' ? ' is-degraded' : '';
|
||||
const titles = (stat.issues || []).map(function (issue) {
|
||||
return (KIND_LABEL[issue.kind] || '') + ' ' + issue.title;
|
||||
});
|
||||
const tooltip = stat.statDate + ' · ' + (STATUS_LABEL[stat.status] || '정상')
|
||||
+ (titles.length ? '\n' + titles.join('\n') : '');
|
||||
return '<button type="button" class="as-bar' + statusClass + '"'
|
||||
+ ' data-date="' + escapeHtml(stat.statDate) + '"'
|
||||
+ ' title="' + escapeHtml(tooltip) + '"></button>';
|
||||
}).join('');
|
||||
|
||||
if (fromLabel) fromLabel.textContent = stats[0].statDate;
|
||||
|
||||
chart.querySelectorAll('.as-bar').forEach(function (bar) {
|
||||
bar.addEventListener('click', function () {
|
||||
window.location.href = issuesHref({ date: bar.getAttribute('data-date') });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- ❸ My APIs ----------------
|
||||
function renderMyApis(apis) {
|
||||
const card = document.getElementById('myApisCard');
|
||||
if (!card) return;
|
||||
if (!apis || !apis.length) {
|
||||
card.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeClass = {
|
||||
OUTAGE: 'is-outage',
|
||||
DEGRADED: 'is-degraded',
|
||||
MAINTENANCE: 'is-maintenance',
|
||||
NORMAL: 'is-normal'
|
||||
};
|
||||
|
||||
document.getElementById('myApisCount').textContent = apis.length + '건';
|
||||
document.getElementById('myApisBody').innerHTML = apis.map(function (api) {
|
||||
const cls = badgeClass[api.currentStatus] || 'is-muted';
|
||||
return '<tr data-api-id="' + escapeHtml(api.apiId) + '" data-api-name="' + escapeHtml(api.apiName) + '">'
|
||||
+ '<td><span class="as-api-name">' + escapeHtml(api.apiName) + '</span></td>'
|
||||
+ '<td><span class="as-badge ' + cls + '">' + escapeHtml(api.currentStatusLabel) + '</span></td>'
|
||||
+ '<td class="as-num">' + formatPercent(api.uptime90d) + '%</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
|
||||
card.style.display = '';
|
||||
card.querySelectorAll('tbody tr').forEach(function (row) {
|
||||
row.addEventListener('click', function () {
|
||||
window.location.href = issuesHref({
|
||||
apiId: row.getAttribute('data-api-id'),
|
||||
apiName: row.getAttribute('data-api-name')
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- ❹ 점검 사항 ----------------
|
||||
function renderMaintenance(cards) {
|
||||
const list = document.getElementById('maintenanceList');
|
||||
const count = document.getElementById('maintenanceCount');
|
||||
|
||||
if (!cards || !cards.length) {
|
||||
count.textContent = '0건';
|
||||
list.innerHTML = '<div class="as-empty">예정된 점검이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
count.textContent = cards.length + '건';
|
||||
list.innerHTML = cards.map(function (card) {
|
||||
const start = parseDateTime(card.startedAt);
|
||||
const ongoing = start && start <= now;
|
||||
const phase = ongoing ? '진행중' : '예정';
|
||||
const range = formatDateTime(card.startedAt)
|
||||
+ (card.endAt ? ' ~ ' + formatDateTime(card.endAt) : '')
|
||||
+ (card.durationMinutes != null ? ' (' + formatDuration(card.durationMinutes) + ')' : '');
|
||||
|
||||
return '<article class="as-maint-card' + (ongoing ? ' is-ongoing' : '') + '">'
|
||||
+ '<div class="as-maint-head">'
|
||||
+ ' <h3 class="as-maint-title">' + escapeHtml(card.title) + '</h3>'
|
||||
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
|
||||
+ '</div>'
|
||||
+ splitHtml(card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '',
|
||||
noticeHtml(card))
|
||||
+ apiPillsHtml(card.impactedApis, '영향 API:', card.hiddenApiCount)
|
||||
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
|
||||
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
|
||||
+ '</div>'
|
||||
+ '</article>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ---------------- ❺ 지난 이슈 사항 ----------------
|
||||
function issueCardHtml(issue) {
|
||||
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
|
||||
const kindBadge = kindClass;
|
||||
|
||||
let inner;
|
||||
if (issue.kind === 'MAINTENANCE') {
|
||||
inner = '<div class="as-tl-block"><div class="as-tl-item state-RESOLVED">'
|
||||
+ '<span class="as-dot"></span>'
|
||||
+ '<p class="as-tl-label">점검 완료</p>'
|
||||
+ (issue.summary ? '<p class="as-tl-body">' + escapeHtml(issue.summary) + '</p>' : '')
|
||||
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(issue.startedAt))
|
||||
+ ' ~ ' + escapeHtml(formatDateTime(issue.endAt)) + '</p>'
|
||||
+ '</div></div>';
|
||||
} else {
|
||||
const items = (issue.timeline || []).map(function (entry) {
|
||||
return '<div class="as-tl-item state-' + escapeHtml(entry.stateAfter || 'NONE') + '">'
|
||||
+ '<span class="as-dot"></span>'
|
||||
+ '<p class="as-tl-label">' + escapeHtml(entry.labelKo || '진행 상황') + '</p>'
|
||||
+ '<p class="as-tl-body">' + escapeHtml(entry.body) + '</p>'
|
||||
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(entry.eventAt)) + '</p>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
inner = items
|
||||
? '<div class="as-tl-block">' + items + '</div>'
|
||||
: '<p class="as-tl-body">' + escapeHtml(issue.summary || '상세 진행 내역이 등록되지 않았습니다.') + '</p>';
|
||||
}
|
||||
|
||||
const duration = issue.durationMinutes != null ? ' · ' + formatDuration(issue.durationMinutes) : '';
|
||||
|
||||
return '<div class="as-issue-group">'
|
||||
+ '<div class="as-date-head">' + escapeHtml(formatDate(issue.startedAt)) + '</div>'
|
||||
+ '<article class="as-issue-card ' + kindClass + '">'
|
||||
+ ' <div class="as-issue-meta-row">'
|
||||
+ ' <span class="as-badge ' + kindBadge + '">' + escapeHtml(KIND_LABEL[issue.kind] || '') + '</span>'
|
||||
+ ' <span>' + escapeHtml(formatDateTime(issue.startedAt) + duration) + '</span>'
|
||||
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
||||
+ ' </div>'
|
||||
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
||||
+ splitHtml(inner, noticeHtml(issue))
|
||||
+ apiPillsHtml(issue.impactedApis, '영향 API:', issue.hiddenApiCount)
|
||||
+ '</article>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderRecentIssues(issues) {
|
||||
const list = document.getElementById('recentIssueList');
|
||||
if (!issues || !issues.length) {
|
||||
list.innerHTML = '<div class="as-empty">종결된 이슈가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = issues.map(issueCardHtml).join('');
|
||||
}
|
||||
|
||||
function handleError(target, message) {
|
||||
const element = document.getElementById(target);
|
||||
if (element) element.innerHTML = '<div class="as-empty">' + escapeHtml(message) + '</div>';
|
||||
}
|
||||
|
||||
// ---------------- 초기 로딩 ----------------
|
||||
// 영향 API 태그의 링크 여부를 판단해야 하므로 오픈 API 목록을 먼저 받는다
|
||||
fetchJson(base + '/apis.json')
|
||||
.then(function (apis) {
|
||||
(apis || []).forEach(function (api) {
|
||||
if (api.apiId) linkableApiIds.add(api.apiId);
|
||||
});
|
||||
})
|
||||
.catch(function () { /* 실패 시 전부 링크 없이 표시된다 */ })
|
||||
.then(loadSections);
|
||||
|
||||
function loadSections() {
|
||||
fetchJson(base + '/active.json')
|
||||
.then(renderActiveIncidents)
|
||||
.catch(function () { handleError('activeIncidents', '진행 중 장애 정보를 불러올 수 없습니다.'); });
|
||||
|
||||
fetchJson(base + '/uptime.json?days=' + windowDays)
|
||||
.then(renderUptime)
|
||||
.catch(function () { handleError('uptimeChart', '서비스 상태 정보를 불러올 수 없습니다.'); });
|
||||
|
||||
fetchJson(base + '/maintenance.json')
|
||||
.then(renderMaintenance)
|
||||
.catch(function () { handleError('maintenanceList', '점검 정보를 불러올 수 없습니다.'); });
|
||||
|
||||
fetchJson(base + '/recent-issues.json?size=5')
|
||||
.then(renderRecentIssues)
|
||||
.catch(function () { handleError('recentIssueList', '지난 이슈를 불러올 수 없습니다.'); });
|
||||
|
||||
if (authenticated) {
|
||||
fetchJson(base + '/my-apis.json')
|
||||
.then(renderMyApis)
|
||||
.catch(function () { /* 비인증/권한 없음은 조용히 숨긴다 */ });
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -142,7 +142,11 @@ const customPopups = {
|
||||
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
|
||||
|
||||
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
|
||||
$('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
|
||||
// type 은 열려 있는 동안만 password 로 전환한다.
|
||||
// 상주 DOM 에 password 입력이 있으면 브라우저가 페이지의 다른 텍스트 입력을
|
||||
// 아이디 칸으로 오인해 계정 자동완성을 띄우기 때문 (닫을 때 text 로 복원).
|
||||
$('#passwordPopupInput').val('').removeClass('error')
|
||||
.attr('type', 'password').attr('placeholder', placeholder);
|
||||
$('#passwordPopupError').removeClass('show').text('');
|
||||
|
||||
// 팝업 표시 (modal 구조 사용)
|
||||
@@ -219,8 +223,8 @@ const customPopups = {
|
||||
// Body 스크롤 복원
|
||||
$('body').css('overflow', '');
|
||||
|
||||
// 입력 필드 초기화
|
||||
$('#passwordPopupInput').val('').removeClass('error');
|
||||
// 입력 필드 초기화 (type 도 text 로 복원 — 상주 password 로 남기지 않는다)
|
||||
$('#passwordPopupInput').val('').removeClass('error').attr('type', 'text');
|
||||
$('#passwordPopupError').removeClass('show').text('');
|
||||
|
||||
// 이벤트 리스너 제거
|
||||
|
||||
@@ -285,20 +285,40 @@
|
||||
}
|
||||
|
||||
.pulsing {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
transform-origin: 400px 110px;
|
||||
transform-box: fill-box;
|
||||
animation: pulseLine 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulseLine {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(75px) scale(1.1);
|
||||
/* 하나의 transform에 한 줄로 작성 */
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.5;
|
||||
transform: translateX(75px) scale(1.1);
|
||||
/* 하나의 transform에 한 줄로 작성 */
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ textarea.djb-comment-input {
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
background-color: #fcfcfc;
|
||||
|
||||
&::placeholder {
|
||||
color: #7f8a95;
|
||||
|
||||
@@ -1010,6 +1010,7 @@ select.form-control {
|
||||
resize: none;
|
||||
transition: border-color 0.2s;
|
||||
min-height: 250px;
|
||||
background-color: #fcfcfc;
|
||||
|
||||
&::placeholder {
|
||||
color: #94A3B8;
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
transition: var(--transition-smooth);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--accent-light);
|
||||
@@ -63,6 +64,7 @@
|
||||
background-color: var(--primary-color);
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
|
||||
transition: var(--transition-smooth);
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
p {
|
||||
margin: 0;
|
||||
|
||||
& + p {
|
||||
&+p {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,6 @@
|
||||
.btn,
|
||||
.btn-modal-cancel,
|
||||
.btn-modal-confirm {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 46px;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1024px) {
|
||||
height: clamp(44px, 11.73vw, 60px);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
@@ -128,9 +128,31 @@
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// 헤더가 좁아져도 로고 영역은 줄이지 않는다 (줄임은 가운데 메뉴가 감당)
|
||||
flex-shrink: 0;
|
||||
|
||||
.logo {
|
||||
height: 32px;
|
||||
// 로고 워드마크(DJ Bank) 밑선에 "API Portal" 글자 baseline 맞춤
|
||||
align-items: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
|
||||
img { flex-shrink: 0; }
|
||||
|
||||
.mobile-logo-link {
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-logo-text {
|
||||
align-items: flex-end;
|
||||
line-height: 1;
|
||||
padding-bottom: 2px;
|
||||
// "API / Portal" 로 접히지 않게
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +180,7 @@
|
||||
|
||||
// Logo with modern styling
|
||||
.logo {
|
||||
display: block;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
z-index: 10;
|
||||
@@ -202,7 +224,6 @@
|
||||
|
||||
// Logo link styles
|
||||
.logo-link,
|
||||
.mobile-logo-link,
|
||||
.drawer-logo-link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
@@ -252,7 +273,7 @@
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -263,7 +284,7 @@
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1024px) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -937,7 +958,7 @@
|
||||
|
||||
.sub-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
top: calc(100% + 17px);
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
background: var(--white);
|
||||
@@ -982,6 +1003,8 @@
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
transition: var(--transition-smooth);
|
||||
// 폭이 좁아져도 "서비스 소 / 개" 처럼 메뉴명이 잘리지 않게 한다
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-blue);
|
||||
@@ -1096,7 +1119,7 @@
|
||||
// Mobile Responsive Design
|
||||
// 375px 기준 반응형 - clamp() 사용하여 화면 크기에 비례
|
||||
// ===========================
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1024px) {
|
||||
.global-header {
|
||||
// Figma: 44px 높이, 반투명 배경
|
||||
// 375px 기준 44px → 44/375*100 ≈ 11.73vw, 범위: 44px ~ 60px
|
||||
@@ -1247,7 +1270,7 @@
|
||||
}
|
||||
|
||||
// Mobile responsive
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1024px) {
|
||||
.header-user-info {
|
||||
display: none; // Hide on mobile, use drawer instead
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
@use 'pages/service' as *;
|
||||
@use 'pages/api-statistics' as *;
|
||||
@use 'pages/webhook' as *;
|
||||
@use 'pages/api-status' as *;
|
||||
|
||||
// 6. Themes
|
||||
@use 'themes/dark' as *;
|
||||
|
||||
@@ -571,7 +571,7 @@
|
||||
// Result Page Styles (아이디 찾기 결과 페이지)
|
||||
.account-recovery-result {
|
||||
width: 100%;
|
||||
padding: 60px 40px;
|
||||
padding: 35px 0px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
text-align: center;
|
||||
|
||||
@@ -690,6 +690,44 @@
|
||||
border-radius: 12px;
|
||||
padding: 16px 24px;
|
||||
|
||||
&.info-box-highlight {
|
||||
background: linear-gradient(135deg, rgba(0, 73, 180, 0.06) 0%, rgba(0, 73, 180, 0.02) 100%);
|
||||
border: 1.5px solid rgba(0, 73, 180, 0.25);
|
||||
box-shadow: 0 8px 24px rgba(0, 73, 180, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transform: translateY(15px);
|
||||
opacity: 0;
|
||||
|
||||
.highlight-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
animation: pulseIcon 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #1e293b;
|
||||
text-align: center;
|
||||
|
||||
strong {
|
||||
color: #0049B4;
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #4685ef;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 15px;
|
||||
@@ -725,6 +763,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseIcon {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 0px rgba(0, 73, 180, 0));
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
filter: drop-shadow(0 0 6px rgba(0, 73, 180, 0.2));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Mobile Design (sm: 768px breakpoint)
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -785,7 +844,7 @@
|
||||
.auth-input-group {
|
||||
.auth-timer {
|
||||
font-size: 12px;
|
||||
right: 100px;
|
||||
right: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -467,16 +467,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
&-badge {
|
||||
/* 그룹 배지 + 현재 상태 태그를 한 줄에 둔다.
|
||||
기존 그룹 배지가 갖고 있던 절대 위치(top 39 / left 32)를 이 래퍼가 이어받는다 */
|
||||
&-badges {
|
||||
position: absolute;
|
||||
top: 39px;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-badge {
|
||||
background-color: #f2f2f3;
|
||||
border-radius: 10px;
|
||||
padding: 2px 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
@@ -484,9 +495,34 @@
|
||||
font-weight: 500;
|
||||
color: #1b4ab7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
/* 현재 상태 태그. 색 규격은 API Status 화면(.as-badge)과 동일.
|
||||
상태를 못 받았을 때(빈 값)는 자리만 차지하지 않도록 숨긴다 */
|
||||
&-status {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: #eef1f5;
|
||||
color: #4b5563;
|
||||
|
||||
&:empty { display: none; }
|
||||
|
||||
&.is-outage { background: #feeae7; color: #c8362f; }
|
||||
&.is-maintenance { background: #e7f0fb; color: #0a66c2; }
|
||||
&.is-degraded { background: #fef3c7; color: #c77800; }
|
||||
&.is-normal { background: #e7f6ec; color: #1b8c4a; }
|
||||
}
|
||||
|
||||
&-title {
|
||||
position: absolute;
|
||||
top: 86px;
|
||||
@@ -846,7 +882,7 @@
|
||||
}
|
||||
|
||||
// API Overview Card (Flat Style)
|
||||
/* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
|
||||
/* "기본 정보" 헤더: 좌측 제목 + 우측 현재 상태/상태 이력 */
|
||||
.api-basic-info-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -855,6 +891,41 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 현재 상태 배지 + "상태 이력 확인" 버튼.
|
||||
색 규격은 API Status 화면(.as-badge)과 동일하게 맞춘다 */
|
||||
.api-status-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.api-status-inline__label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.api-status-inline__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
background: #eef1f5;
|
||||
color: #4b5563;
|
||||
|
||||
&.is-outage { background: #feeae7; color: #c8362f; }
|
||||
&.is-maintenance { background: #e7f0fb; color: #0a66c2; }
|
||||
&.is-degraded { background: #fef3c7; color: #c77800; }
|
||||
&.is-normal { background: #e7f6ec; color: #1b8c4a; }
|
||||
&.is-loading { color: #6b7280; }
|
||||
&.is-muted { background: #eef1f5; color: #6b7280; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 하단 중앙 "API 사용 신청" 버튼 */
|
||||
.api-apply-footer {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,959 @@
|
||||
// ============================================================
|
||||
// API Status 페이지 (/apistatus, /apistatus/issues)
|
||||
// 설계: architecture/01-api-status/02-screen-design
|
||||
// 타이틀 배너: Figma eapim-portal node 1:16
|
||||
// ============================================================
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
// API Status 화면은 제목 포함 전부 본문 폰트(Spoqa Han Sans)를 쓴다.
|
||||
// 전역 타이포가 h1~h6 에 $font-family-heading(OneShinhan 우선)을 지정하므로 페이지 범위에서 되돌린다.
|
||||
.as-title-banner,
|
||||
.as-title-banner h1,
|
||||
.api-status,
|
||||
.api-status h1,
|
||||
.api-status h2,
|
||||
.api-status h3,
|
||||
.api-status h4 {
|
||||
font-family: $font-family-primary;
|
||||
}
|
||||
|
||||
// 타이틀 배너 — 레이아웃의 .container 안에 놓이므로 컨테이너 폭을 따른다
|
||||
.as-title-banner {
|
||||
// 상단 여백은 fixed 헤더(80px) 아래로 배너를 내리기 위한 값이다
|
||||
margin: 44px 0 28px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(110deg, #003080 0%, #0049b4 60%, #066ae5 100%);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
.as-title-banner-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.as-title-meta {
|
||||
text-align: right;
|
||||
|
||||
p { margin: 0; font-weight: 400; }
|
||||
}
|
||||
|
||||
.as-title-meta-main {
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
|
||||
strong { font-weight: 500; }
|
||||
}
|
||||
|
||||
.as-title-meta-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #c9d1dc;
|
||||
}
|
||||
|
||||
.as-title-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
background: #56e3a6;
|
||||
box-shadow: 0 0 0 4px rgba(86, 227, 166, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.as-title-banner-inner { padding: 24px 20px; }
|
||||
|
||||
h1 { font-size: 20px; }
|
||||
|
||||
.as-title-meta { text-align: left; }
|
||||
}
|
||||
}
|
||||
|
||||
.api-status {
|
||||
// ---- 토큰 (설계서 색상 규격) ----
|
||||
--as-ok: #1b8c4a;
|
||||
--as-ok-bg: #e7f6ec;
|
||||
--as-warn: #c77800;
|
||||
--as-warn-bg: #fef3c7;
|
||||
--as-err: #c8362f;
|
||||
--as-err-bg: #feeae7;
|
||||
--as-info: #0a66c2;
|
||||
--as-info-bg: #e7f0fb;
|
||||
--as-both: #6b21a8;
|
||||
--as-gray-bg: #eef1f5;
|
||||
// 이슈 없음 = 정상. API Status 메인 가동률 바와 같은 색을 쓴다
|
||||
--as-none-bg: var(--as-ok);
|
||||
--as-border: #e0e6f1;
|
||||
--as-border-strong: #d1d5db;
|
||||
--as-text: #020616;
|
||||
--as-text-2: #252b37;
|
||||
--as-text-3: #4b5563;
|
||||
--as-muted: #6b7280;
|
||||
--as-radius: 12px;
|
||||
--as-pill: 999px;
|
||||
|
||||
display: grid;
|
||||
// minmax(0,1fr): grid 자식의 암묵적 min-width(auto)를 해제한다.
|
||||
// 90일 막대(90개 × 최소폭)가 카드의 최소 폭을 밀어올려 좁은 화면에서 카드가 잘리는 것 방지
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
padding: 0 0 70px;
|
||||
|
||||
// 상단 헤더가 fixed 라 브레드크럼 위를 덮는다. 첫 카드가 헤더에 붙지 않도록 띄운다.
|
||||
&.is-standalone { padding-top: 44px; }
|
||||
|
||||
// 제목(h1~h3) 외 본문은 기본 굵기를 쓴다.
|
||||
// 포털 전역 타이포가 본문을 600 으로 잡고 있어 페이지 범위에서 되돌린다.
|
||||
font-weight: 400;
|
||||
|
||||
p,
|
||||
span,
|
||||
li,
|
||||
a,
|
||||
td,
|
||||
th,
|
||||
label,
|
||||
button,
|
||||
input,
|
||||
select { font-weight: 400; }
|
||||
|
||||
b,
|
||||
strong { font-weight: 500; }
|
||||
|
||||
// ---------------- 공통 카드/배지 ----------------
|
||||
.as-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--as-border);
|
||||
border-radius: var(--as-radius);
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.as-card-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--as-text);
|
||||
}
|
||||
|
||||
.as-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--as-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.as-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--as-pill);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-incident,
|
||||
&.is-outage { background: var(--as-err-bg); color: var(--as-err); }
|
||||
&.is-maintenance { background: var(--as-info-bg); color: var(--as-info); }
|
||||
&.is-degraded { background: var(--as-warn-bg); color: var(--as-warn); }
|
||||
&.is-normal { background: var(--as-ok-bg); color: var(--as-ok); }
|
||||
&.is-muted { background: var(--as-gray-bg); color: var(--as-text-3); }
|
||||
}
|
||||
|
||||
.as-section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px 4px 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.as-desc { font-size: 13px; color: var(--as-muted); }
|
||||
|
||||
.as-count {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--as-muted);
|
||||
background: #f6f9fb;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--as-pill);
|
||||
}
|
||||
}
|
||||
|
||||
.as-empty {
|
||||
color: var(--as-muted);
|
||||
font-size: 14px;
|
||||
padding: 26px;
|
||||
background: #fff;
|
||||
border: 1px dashed var(--as-border);
|
||||
border-radius: var(--as-radius);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.as-api-pills {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--as-text-3);
|
||||
|
||||
.as-api-pill {
|
||||
background: var(--as-gray-bg);
|
||||
color: var(--as-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--as-pill);
|
||||
}
|
||||
|
||||
// 오픈 API 목록에 있는 API — 상세로 이동
|
||||
a.as-api-pill {
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--as-info-bg);
|
||||
color: var(--as-info);
|
||||
}
|
||||
}
|
||||
|
||||
// 목록에 없는 API — 링크 없음, 채도를 낮춰 구분
|
||||
.as-api-pill.is-unlinked {
|
||||
background: #f3f5f8;
|
||||
color: var(--as-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 ("GW 인터페이스 3건")
|
||||
.as-api-pill.is-gw {
|
||||
background: #fff;
|
||||
border: 1px dashed var(--as-border-strong);
|
||||
color: var(--as-muted);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
// 타임라인 | 공지 2열. 데스크탑(961px~)에서만 갈라지고 그 아래는 세로로 쌓인다.
|
||||
// minmax(0, 1fr) 은 공지 본문의 긴 URL·표가 칸을 밀어내지 못하게 하는 grid 관용구.
|
||||
.as-card-split {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
|
||||
@media (min-width: 961px) {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
// 감싼 두 블록의 자체 세로 margin 은 grid gap 으로 대체한다.
|
||||
// 카드 컨텍스트를 함께 써서 원 규칙(.as-alert-card .as-alert-timeline 등)보다 특이도를 높인다.
|
||||
.as-card-split > .as-alert-notice { margin-top: 0; }
|
||||
.as-alert-card .as-card-split > .as-alert-timeline { margin-top: 0; }
|
||||
.as-maint-card .as-card-split > .as-maint-body { margin-top: 0; }
|
||||
|
||||
// 이슈에 연결된 공지 본문 (진행 중 장애 카드 · 점검 카드 공용).
|
||||
// 본문 길이가 제각각이라 높이를 제한하고 안쪽만 스크롤시킨다.
|
||||
.as-alert-notice {
|
||||
margin-top: 16px;
|
||||
border: 1px solid var(--as-border);
|
||||
border-radius: var(--as-radius);
|
||||
background: var(--as-gray-bg);
|
||||
padding: 14px 16px;
|
||||
|
||||
.as-alert-notice-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
|
||||
strong { font-size: 14px; color: var(--as-text); }
|
||||
}
|
||||
|
||||
.as-alert-notice-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--as-pill);
|
||||
background: var(--as-info-bg);
|
||||
color: var(--as-info);
|
||||
}
|
||||
|
||||
.as-alert-notice-body {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--as-text-2);
|
||||
word-break: break-word;
|
||||
|
||||
img, table { max-width: 100%; }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
.as-alert-notice-link {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--as-info);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 진행 중 장애 카드 ----------------
|
||||
.as-alert-card {
|
||||
border-left: 4px solid var(--as-err);
|
||||
|
||||
.as-alert-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.as-alert-title {
|
||||
margin: 8px 0 6px;
|
||||
font-size: 20px;
|
||||
color: var(--as-err);
|
||||
}
|
||||
|
||||
.as-meta { font-size: 13px; color: var(--as-text-3); }
|
||||
|
||||
.as-alert-timeline {
|
||||
margin-top: 16px;
|
||||
border-top: 1px dashed var(--as-border);
|
||||
padding-top: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.as-tl-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 76px 1fr;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
.as-tl-time {
|
||||
color: var(--as-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.as-tl-who {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
height: -webkit-fit-content;
|
||||
height: fit-content;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--as-pill);
|
||||
background: var(--as-gray-bg);
|
||||
color: var(--as-text-2);
|
||||
|
||||
&.is-system { background: var(--as-info-bg); color: var(--as-info); }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 진행 중 장애가 노출 상한을 넘을 때 안내
|
||||
.as-alert-overflow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 20px;
|
||||
border: 1px dashed var(--as-err);
|
||||
border-radius: var(--as-radius);
|
||||
background: var(--as-err-bg);
|
||||
font-size: 13px;
|
||||
color: var(--as-err);
|
||||
|
||||
a { color: var(--as-err); font-weight: 500; text-decoration: none; }
|
||||
}
|
||||
|
||||
// ---------------- 90일 서비스 상태 ----------------
|
||||
.as-uptime {
|
||||
.as-bar-chart {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-end;
|
||||
height: 56px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.as-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
font-size: 11px;
|
||||
color: var(--as-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.as-bar {
|
||||
flex: 1 0 4px;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--as-ok);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&.is-outage { background: var(--as-err); }
|
||||
&.is-maintenance { background: var(--as-info); }
|
||||
&.is-degraded { background: var(--as-warn); }
|
||||
&:hover { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.as-legend {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--as-muted);
|
||||
|
||||
.as-sw {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
|
||||
&.is-ok { background: var(--as-ok); }
|
||||
&.is-outage { background: var(--as-err); }
|
||||
&.is-maintenance { background: var(--as-info); }
|
||||
&.is-degraded { background: var(--as-warn); }
|
||||
&.is-none { background: var(--as-none-bg); }
|
||||
// 장애·지연·점검 중 2종 이상이 겹친 날
|
||||
&.is-mixed,
|
||||
&.is-both { background: var(--as-both); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- My APIs ----------------
|
||||
.as-my-apis {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th,
|
||||
td { text-align: left; padding: 12px 14px; }
|
||||
|
||||
th {
|
||||
background: #f6f9fb;
|
||||
color: var(--as-text-3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--as-border);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid var(--as-border);
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child { border-bottom: 0; }
|
||||
&:hover { background: #f6f9fb; }
|
||||
}
|
||||
|
||||
.as-api-name { font-weight: 500; color: var(--as-text); }
|
||||
.as-num { font-variant-numeric: tabular-nums; }
|
||||
}
|
||||
|
||||
// ---------------- 점검 사항 ----------------
|
||||
.as-maint-list { display: grid; gap: 14px; }
|
||||
|
||||
.as-maint-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--as-border);
|
||||
border-left: 4px solid var(--as-info);
|
||||
border-radius: var(--as-radius);
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
|
||||
&.is-ongoing { border-left-color: var(--as-warn); }
|
||||
|
||||
.as-maint-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.as-maint-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--as-text); }
|
||||
|
||||
.as-schedule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--as-info);
|
||||
background: var(--as-info-bg);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--as-pill);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&.is-ongoing .as-schedule { color: var(--as-warn); background: var(--as-warn-bg); }
|
||||
|
||||
.as-maint-body { margin: 14px 0 12px; font-size: 14px; line-height: 1.6; color: var(--as-text-3); }
|
||||
|
||||
.as-maint-meta {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--as-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 지난 이슈 / 이슈 이력 카드 ----------------
|
||||
.as-issue-list { display: grid; gap: 24px; }
|
||||
|
||||
.as-issue-group {
|
||||
.as-date-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin: 0 4px 10px;
|
||||
padding-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--as-text-2);
|
||||
border-bottom: 1px solid var(--as-border);
|
||||
}
|
||||
}
|
||||
|
||||
.as-issue-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--as-border);
|
||||
border-radius: var(--as-radius);
|
||||
padding: 22px 26px 24px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
|
||||
&.is-incident { border-left: 4px solid var(--as-err); }
|
||||
&.is-degraded { border-left: 4px solid var(--as-warn); }
|
||||
&.is-maintenance { border-left: 4px solid var(--as-info); }
|
||||
|
||||
.as-issue-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--as-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.as-issue-title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
&.is-incident .as-issue-title { color: var(--as-err); }
|
||||
&.is-degraded .as-issue-title { color: var(--as-warn); }
|
||||
&.is-maintenance .as-issue-title { color: var(--as-info); }
|
||||
}
|
||||
|
||||
// 세로 타임라인
|
||||
.as-tl-block {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 1px;
|
||||
background: var(--as-border);
|
||||
}
|
||||
}
|
||||
|
||||
.as-tl-item {
|
||||
position: relative;
|
||||
padding-bottom: 22px;
|
||||
|
||||
&:last-child { padding-bottom: 0; }
|
||||
|
||||
+ .as-tl-item {
|
||||
margin-top: 4px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px dashed var(--as-border);
|
||||
}
|
||||
|
||||
.as-dot {
|
||||
position: absolute;
|
||||
left: -24px;
|
||||
top: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
background: var(--as-muted);
|
||||
box-shadow: 0 0 0 1px var(--as-border-strong);
|
||||
}
|
||||
|
||||
&.state-RESOLVED .as-dot { background: var(--as-ok); box-shadow: 0 0 0 1px var(--as-ok); }
|
||||
&.state-MONITORING .as-dot { background: var(--as-warn); box-shadow: 0 0 0 1px var(--as-warn); }
|
||||
&.state-IDENTIFIED .as-dot { background: #c95a0f; box-shadow: 0 0 0 1px #c95a0f; }
|
||||
&.state-INVESTIGATING .as-dot { background: var(--as-warn); box-shadow: 0 0 0 1px var(--as-warn); }
|
||||
&.state-CANCELED .as-dot { background: var(--as-muted); box-shadow: 0 0 0 1px var(--as-muted); }
|
||||
|
||||
.as-tl-label { margin: 0 0 8px; font-size: 14px; font-weight: 500; color: var(--as-text); }
|
||||
.as-tl-body { margin: 0 0 8px; font-size: 14px; line-height: 1.6; color: var(--as-text); white-space: pre-line; }
|
||||
|
||||
.as-tl-ts {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--as-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.as-more-link {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// ---------------- 이슈 이력 페이지 ----------------
|
||||
.as-index-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: stretch;
|
||||
height: 44px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.as-index-cell {
|
||||
flex: 1 0 4px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--as-none-bg); // 이슈 없음 = 정상(메인 가동률 바와 동일)
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&.has-incident { background: var(--as-err); }
|
||||
&.has-degraded { background: var(--as-warn); }
|
||||
&.has-maintenance { background: var(--as-info); }
|
||||
// 유형이 2종 이상 섞인 날
|
||||
&.has-mixed,
|
||||
&.has-both { background: var(--as-both); }
|
||||
&.is-selected { outline: 2px solid var(--as-text-2); outline-offset: 1px; }
|
||||
&:hover { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.as-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
background: #fff;
|
||||
border: 1px solid var(--as-border);
|
||||
border-radius: var(--as-radius);
|
||||
padding: 14px 18px;
|
||||
font-size: 13px;
|
||||
|
||||
.as-filter-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.as-filter-label {
|
||||
font-weight: 500;
|
||||
color: var(--as-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.as-filter-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
color: var(--as-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.as-filter-count b { color: var(--as-text); }
|
||||
}
|
||||
|
||||
.as-input {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--as-border-strong);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--as-text);
|
||||
background: #fff;
|
||||
max-width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--as-info);
|
||||
box-shadow: 0 0 0 2px rgba(10, 102, 194, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// 값 지우기 버튼이 붙는 입력 컨트롤 (날짜 / API)
|
||||
.as-field-control {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
input { padding-right: 30px; }
|
||||
|
||||
// type=search 의 네이티브 지우기 버튼은 자체 버튼과 중복이라 감춘다
|
||||
input[type='search'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
&::-webkit-search-cancel-button,
|
||||
&::-webkit-search-decoration { -webkit-appearance: none; appearance: none; display: none; }
|
||||
}
|
||||
}
|
||||
|
||||
.as-field-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--as-gray-bg);
|
||||
color: var(--as-text-3);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: var(--as-border-strong); color: var(--as-text); }
|
||||
}
|
||||
|
||||
.as-filter-field.has-value .as-field-clear { display: block; }
|
||||
|
||||
// 날짜 입력은 네이티브 달력 아이콘과 겹치지 않게 여유를 둔다
|
||||
#filterDateInput { padding-right: 52px; }
|
||||
|
||||
// API 라이브서치 콤보박스
|
||||
.as-combo {
|
||||
.as-field-control {
|
||||
min-width: 240px;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
input { width: 100%; }
|
||||
|
||||
.as-combo-list {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 280px;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid var(--as-border-strong);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.as-combo-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover { background: var(--as-info-bg); }
|
||||
}
|
||||
|
||||
.as-combo-empty {
|
||||
padding: 10px;
|
||||
color: var(--as-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 포탈에 게시되지 않은 API 필터 안내
|
||||
.as-unlisted-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 18px;
|
||||
border: 1px solid var(--as-border);
|
||||
border-left: 4px solid var(--as-muted);
|
||||
border-radius: var(--as-radius);
|
||||
background: #f6f8fa;
|
||||
font-size: 13px;
|
||||
color: var(--as-text-3);
|
||||
|
||||
b { color: var(--as-text); }
|
||||
|
||||
.as-unlisted-icon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--as-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.as-pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// 필터/페이저 버튼 (전역 버튼 클래스에 의존하지 않는다)
|
||||
.as-btn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--as-border-strong);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--as-text-2);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: #f6f9fb; }
|
||||
|
||||
&.is-current {
|
||||
border-color: var(--as-info);
|
||||
background: var(--as-info);
|
||||
color: #fff;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 반응형 ----------------
|
||||
@media (max-width: 960px) {
|
||||
.as-filter-bar {
|
||||
gap: 10px;
|
||||
|
||||
.as-filter-right { margin-left: 0; width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
.as-my-apis {
|
||||
table { display: block; overflow-x: auto; white-space: nowrap; }
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
gap: 16px;
|
||||
padding-bottom: 48px;
|
||||
|
||||
&.is-standalone { padding-top: 32px; }
|
||||
|
||||
.as-card { padding: 18px 16px; }
|
||||
|
||||
.as-card-head { margin-bottom: 12px; }
|
||||
|
||||
.as-alert-card .as-tl-row { grid-template-columns: 52px 1fr; }
|
||||
.as-alert-card .as-tl-who { display: none; }
|
||||
.as-alert-card .as-alert-title { font-size: 18px; }
|
||||
|
||||
// 필터: 라벨 + 입력을 한 줄씩 쌓는다
|
||||
.as-filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 14px;
|
||||
|
||||
.as-filter-field { flex-direction: column; align-items: flex-start; gap: 6px; }
|
||||
|
||||
// 세로 스택에서는 flex-basis 가 높이로 해석되므로 반드시 해제한다
|
||||
.as-input,
|
||||
.as-field-control { flex: 0 0 auto; width: 100%; min-width: 0; }
|
||||
|
||||
.as-filter-right { justify-content: space-between; }
|
||||
}
|
||||
|
||||
// 90일 바: 셀을 균등 축소하지 않고 최소 터치 폭을 보장한 뒤 가로 스크롤
|
||||
.as-index-bar {
|
||||
height: 40px;
|
||||
|
||||
.as-index-cell { flex: 0 0 12px; }
|
||||
}
|
||||
|
||||
.as-uptime .as-bar-chart .as-bar { flex: 0 0 12px; }
|
||||
|
||||
.as-issue-card { padding: 18px 16px; }
|
||||
.as-issue-title { font-size: 17px; }
|
||||
|
||||
.as-maint-card { padding: 18px 16px; }
|
||||
|
||||
.as-uptime .as-bar-chart { height: 44px; }
|
||||
|
||||
.as-issue-list { gap: 16px; }
|
||||
|
||||
.as-pager button { min-width: 30px; padding: 0 8px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.as-section-title h2 { font-size: 18px; }
|
||||
|
||||
.as-legend { gap: 8px; font-size: 11px; }
|
||||
|
||||
.as-tl-block { padding-left: 18px; }
|
||||
.as-tl-item .as-dot { left: -18px; }
|
||||
}
|
||||
}
|
||||
@@ -168,16 +168,16 @@
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dadada;
|
||||
border: 1px solid #DFDFDF;
|
||||
border-radius: $border-radius-md;
|
||||
background-color: $white;
|
||||
background-color: #fcfcfc;
|
||||
font-size: 14px;
|
||||
color: $text-dark;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: #dadada;
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@@ -1130,7 +1130,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #E2E8F0 !important;
|
||||
background-color: #ededed !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2929,7 +2929,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
.s1-title,
|
||||
.s2-title,
|
||||
.s3-title {
|
||||
font-size: 30px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0d0e11;
|
||||
line-height: 1;
|
||||
@@ -3261,7 +3261,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.s1-upload-inner > img {
|
||||
.s1-upload-inner>img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@@ -423,342 +423,6 @@
|
||||
// API Search section styles - 검색 섹션
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/* 원래 있던 검색 영역 스타일 (주석 처리)
|
||||
.api-search-section {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
//margin-top: -72px;
|
||||
overflow: hidden;
|
||||
background: #e9f9ff;
|
||||
height: 283px;
|
||||
|
||||
.search-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url('/img/bg_main_intersect.svg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-content-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
z-index: 1;
|
||||
|
||||
@include respond-to('md') {
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 캐릭터와 텍스트 가로 배치
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
padding-top: 18px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.search-character {
|
||||
width: 139px;
|
||||
height: 133px;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@include respond-to('md') {
|
||||
width: 108px;
|
||||
height: 104px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 68x65px
|
||||
width: 61px;
|
||||
height: 59px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-text-content {
|
||||
text-align: left;
|
||||
|
||||
@include respond-to('md') {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 텍스트 왼쪽 정렬
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 32px;
|
||||
line-height: 1.3;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 14px, Bold
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0px 0 27px;
|
||||
z-index: 1;
|
||||
|
||||
@include respond-to('md') {
|
||||
padding: 27px 18px 18px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 좌측 24px, 우측 25px 패딩으로 좌측 정렬
|
||||
justify-content: flex-start;
|
||||
padding: 0px 25px 11px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-form {
|
||||
width: 100%;
|
||||
max-width: 816px;
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 288px 고정 너비
|
||||
//width: 288px;
|
||||
//max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
background: #FFFFFF;
|
||||
border: 5px solid #0049B4;
|
||||
border-radius: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 22px;
|
||||
|
||||
@include respond-to('md') {
|
||||
height: 54px;
|
||||
border: 4px solid #0049B4;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 320x36px, 1px 파란색 테두리
|
||||
height: 32px;
|
||||
border: 3px solid #0049B4;
|
||||
border-radius: 16px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
padding: 0 18px;
|
||||
|
||||
&::placeholder {
|
||||
color: #B3B3B3;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 14px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 10px, Medium, placeholder 색상 #8C959F
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 0 7px;
|
||||
|
||||
&::placeholder {
|
||||
color: #8C959F;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 30px;
|
||||
height: 31px;
|
||||
|
||||
@include respond-to('md') {
|
||||
width: 25px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 16x16px 아이콘
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.hashtag-section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 11px;
|
||||
padding: 0 18px 0px;
|
||||
z-index: 1;
|
||||
|
||||
@include respond-to('md') {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 18px 27px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 가로 배치, 작은 간격
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 11px;
|
||||
padding: 0 18px 18px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.hashtag-label {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
white-space: nowrap;
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 11px, Bold
|
||||
font-size: 11px;
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
|
||||
.hashtag-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include respond-to('md') {
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 7px 간격, wrap
|
||||
gap: 7px;
|
||||
justify-content: left;
|
||||
}
|
||||
}
|
||||
|
||||
.hashtag-link {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: #0049B4;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 11px
|
||||
font-size: 11px;
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
|
||||
.hashtag-separator {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
color: #000000;
|
||||
user-select: none;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 세로 구분선 (|)
|
||||
font-size: 9px;
|
||||
color: #212529;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/* 피그마 시안 적용 카드 레이아웃 스타일 */
|
||||
.search-container {
|
||||
@@ -1195,8 +859,9 @@
|
||||
|
||||
.api-cards-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 29px;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
//@include respond-to('lg') {
|
||||
// gap: 22px;
|
||||
@@ -1217,6 +882,10 @@
|
||||
}
|
||||
|
||||
.api-card {
|
||||
// 1행 4열 고정 (gap 29px * 3 = 87px)
|
||||
flex: 0 0 calc((100% - 87px) / 4);
|
||||
max-width: calc((100% - 87px) / 4);
|
||||
box-sizing: border-box;
|
||||
height: 288px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -1245,12 +914,16 @@
|
||||
}
|
||||
|
||||
@include respond-to('md') {
|
||||
flex: 0 0 calc(50% - 9px);
|
||||
max-width: calc(50% - 9px);
|
||||
width: calc(50% - 9px);
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 144px width, auto height, padding 18px 17px
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-width: unset;
|
||||
@@ -1288,14 +961,21 @@
|
||||
|
||||
.card-description {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 13px;
|
||||
font-size: 16px;
|
||||
color: var(--text-gray);
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5;
|
||||
max-height: 3em;
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 11px Medium, 색상 #515151
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
|
||||
@@ -1425,7 +1105,7 @@
|
||||
letter-spacing: 1px;
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,7 +1127,7 @@
|
||||
}
|
||||
|
||||
.info-description {
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 36px;
|
||||
@@ -1459,7 +1139,7 @@
|
||||
|
||||
@include respond-to('sm') {
|
||||
order: 3;
|
||||
font-size: 13px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
word-break: keep-all;
|
||||
@@ -1637,424 +1317,6 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Support Center Section - 비지니스의 시작 (Figma Design)
|
||||
// -----------------------------------------------------------------------------
|
||||
/* 원래 있던 support-center 스타일 (주석 처리)
|
||||
.support-center {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
padding-top: 92px;
|
||||
padding-bottom: 113px;
|
||||
background: #eef7fd;
|
||||
overflow: hidden;
|
||||
height: 648px;
|
||||
|
||||
@include respond-to('md') {
|
||||
height: auto;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 353px 높이, 패딩 54px 18px
|
||||
min-height: unset;
|
||||
height: auto;
|
||||
padding: 54px 18px;
|
||||
}
|
||||
|
||||
.support-background {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 1920px;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px; // Figma: top-[2911px] relative to page, section starts at ~2895px
|
||||
left: 46%; // Approximate positioning
|
||||
width: 624px;
|
||||
height: 537px;
|
||||
background-image: url('/img/bg_support_center.png'); // Assuming this image exists or use placeholder
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.5; // Adjust based on visual
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
|
||||
// Figma 모바일: 포인트 이미지 상단 우측
|
||||
&::before {
|
||||
top: 0;
|
||||
left: auto;
|
||||
right: 0;
|
||||
width: 168px;
|
||||
height: 159px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 23px;
|
||||
height: 443px;
|
||||
|
||||
@include respond-to('md') {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 36px;
|
||||
text-align: center;
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 타이틀 영역
|
||||
margin-bottom: 22px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 25px;
|
||||
line-height: 1.4;
|
||||
color: #000000;
|
||||
margin: 0 0 $spacing-lg 0;
|
||||
text-align: left;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 32px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 타이틀 스타일
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.32px;
|
||||
}
|
||||
|
||||
.title-regular {
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.title-bold {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 18px Bold
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.support-grid {
|
||||
display: flex;
|
||||
gap: 29px;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
max-width: 1228px; // 598 + 283 + 283 + (32 * 2) = 1228px
|
||||
height: auto;
|
||||
min-height: 288px;
|
||||
margin: 0 auto;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include respond-to('mobile') {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// 가로 배치는 카드가 너무 좁아지므로 1열 세로 배치로 변경
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 통합 Support Card
|
||||
.support-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 18px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 288px; // 모든 카드 동일 높이
|
||||
|
||||
@include respond-to('mobile') {
|
||||
width: 100%;
|
||||
max-width: 598px;
|
||||
height: auto;
|
||||
min-height: 234px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 101x168px 카드
|
||||
width: calc(33.333% - 7px);
|
||||
min-width: 0;
|
||||
height: 151px;
|
||||
min-height: 151px;
|
||||
max-width: none;
|
||||
border-radius: 11px;
|
||||
padding: 23px 0 23px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 9px 27px rgba(0, 73, 180, 0.15);
|
||||
|
||||
@include respond-to('sm') {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 43px 아이콘
|
||||
width: 43px !important;
|
||||
height: 43px !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
@include respond-to('sm') {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 29px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin: 0 0 9px 0;
|
||||
line-height: 1.3;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 15-14px Bold
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #515961;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 9px Regular
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공지사항 카드
|
||||
&-notice {
|
||||
width: 349px;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 45px 36px;
|
||||
background: #0049B4;
|
||||
|
||||
@include respond-to('mobile') {
|
||||
width: 100%;
|
||||
max-width: 598px;
|
||||
padding: 45px 36px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 공지사항 카드 스타일
|
||||
width: 100%;
|
||||
padding: 23px 20px 23px;
|
||||
gap: 17px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
color: #FFFFFF;
|
||||
font-size: 27px;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 29px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-size: 14px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 20px;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-size: 9px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: darken(#0049B4, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
// FAQ & Q&A Cards
|
||||
&-faq,
|
||||
&-qna {
|
||||
width: 349px;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 45px 36px;
|
||||
|
||||
@include respond-to('mobile') {
|
||||
width: 100%;
|
||||
max-width: 598px;
|
||||
padding: 36px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: FAQ/Q&A 카드 스타일
|
||||
width: 100%;
|
||||
padding: 23px 20px 23px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
}
|
||||
|
||||
.card-content {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
flex: none;
|
||||
|
||||
h3 {
|
||||
font-size: 27px;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 29px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 14px Bold
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 20px;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
// Figma 모바일: 9px Regular
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FAQ Card Specifics
|
||||
&--faq {
|
||||
background: #DAF0FF;
|
||||
|
||||
.card-content h3 {
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
|
||||
// Q&A Card Specifics
|
||||
&--qna {
|
||||
background: #D9F6F8;
|
||||
|
||||
.card-content h3 {
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/* 피그마 시안 적용 Support Center 그리드 레이아웃 스타일 */
|
||||
.support-center {
|
||||
@@ -2130,9 +1392,10 @@
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--text-gray);
|
||||
line-height: 1.5;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.card-arrow {
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
border-bottom: 1px solid #000;
|
||||
border-bottom: 1px solid #818181;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
}
|
||||
|
||||
.form-label-text {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
.form-input:disabled,
|
||||
.input-readonly {
|
||||
background: #e9ecef !important;
|
||||
background: #ededed !important;
|
||||
}
|
||||
|
||||
.compound-input {
|
||||
@@ -289,6 +289,7 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
color: #4685ef
|
||||
}
|
||||
|
||||
p {
|
||||
|
||||
@@ -136,9 +136,9 @@
|
||||
}
|
||||
|
||||
&--completed {
|
||||
background: #e5f0ff;
|
||||
color: #0a4ea3;
|
||||
border: 1px solid #b3d4ff;
|
||||
background: #d6fbd0;
|
||||
color: #11af4c;
|
||||
border: 1px solid #a0d4b2;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
@@ -159,6 +159,12 @@
|
||||
border: 1px solid #b3d4ff;
|
||||
}
|
||||
|
||||
&--reviewing {
|
||||
background: #e5f0ff;
|
||||
color: #0a4ea3;
|
||||
border: 1px solid #b3d4ff;
|
||||
}
|
||||
|
||||
&--test {
|
||||
background: #e6fffa;
|
||||
color: #0d9488;
|
||||
@@ -352,6 +358,14 @@
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 ("GW 인터페이스 3건")
|
||||
&.is-gw {
|
||||
background-color: #ffffff;
|
||||
border-style: dashed;
|
||||
border-color: #cbd5e1;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-affected-toggle {
|
||||
@@ -443,6 +457,96 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 장애 처리 타임라인 — 상단 장애정보 표와 같은 규격의 간결한 표
|
||||
.notice-detail-timeline {
|
||||
padding-bottom: 40px;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: $spacing-3xl;
|
||||
|
||||
.notice-timeline-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.timeline-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
border-bottom: 1px solid #e3e8f0;
|
||||
|
||||
thead th {
|
||||
background: #f9f9f9;
|
||||
height: 43px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
color: #000;
|
||||
border-bottom: 1px solid #e3e8f0;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid #e3e8f0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.timeline-ts {
|
||||
color: #555;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
line-height: 1.6;
|
||||
white-space: pre-line;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
table-layout: auto;
|
||||
|
||||
colgroup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
thead th,
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.timeline-ts {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 배지 — API Status 타임라인 dot 색 규격과 동일
|
||||
.timeline-state {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
background: #eef1f5;
|
||||
color: #4b5563;
|
||||
|
||||
&.state-RESOLVED { background: #e7f6ec; color: #1b8c4a; }
|
||||
&.state-MONITORING,
|
||||
&.state-INVESTIGATING { background: #fef3c7; color: #c77800; }
|
||||
&.state-IDENTIFIED { background: #fdeee2; color: #c95a0f; }
|
||||
&.state-CANCELED { background: #eef1f5; color: #6b7280; }
|
||||
}
|
||||
}
|
||||
|
||||
// Notice Detail Actions
|
||||
.notice-detail-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -34,6 +34,53 @@
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
padding: $spacing-lg $spacing-md;
|
||||
}
|
||||
|
||||
.invitation_alert_banner {
|
||||
background-color: #f0f8ff;
|
||||
border: 1.5px solid #2196F3;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.08);
|
||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transform: translateY(15px);
|
||||
opacity: 0;
|
||||
margin-bottom: 60px;
|
||||
|
||||
.banner-content-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.banner-icon-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
animation: pulseIcon 2s infinite ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-text-area {
|
||||
strong {
|
||||
color: #1976D2;
|
||||
font-size: 17px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
@@ -90,7 +137,7 @@
|
||||
background: none;
|
||||
padding: 0 0 $spacing-md 0;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid #212529;
|
||||
border-bottom: 1px solid #818181;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
h3 {
|
||||
@@ -315,6 +362,16 @@
|
||||
background-position: right 8px center;
|
||||
}
|
||||
|
||||
.org-form-select.phone-prefix {
|
||||
flex: 1;
|
||||
min-width: 90px;
|
||||
max-width: 130px;
|
||||
padding-right: 28px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: $text-gray;
|
||||
font-weight: $font-weight-semibold;
|
||||
@@ -336,29 +393,31 @@
|
||||
// Select Dropdown
|
||||
.org-form-select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid $border-gray;
|
||||
border-radius: $border-radius-md;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: $text-dark;
|
||||
color: #212529;
|
||||
background: $white;
|
||||
cursor: pointer;
|
||||
transition: $transition-base;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right $spacing-lg center;
|
||||
background-position: right 16px center;
|
||||
padding-right: $spacing-3xl;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary-blue;
|
||||
box-shadow: 0 0 0 3px rgba(75, 155, 255, 0.1);
|
||||
border-color: #3ba4ed;
|
||||
box-shadow: 0 0 0 3px rgba(59, 164, 237, 0.12);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: $gray-bg;
|
||||
color: $text-light;
|
||||
background-color: #F8F9FA;
|
||||
border-color: #E2E8F0;
|
||||
color: #94A3B8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,26 +386,31 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
padding-bottom: 100px;
|
||||
|
||||
.signup-info-box {
|
||||
background: #f5f5f5;
|
||||
border-radius: 12px;
|
||||
padding: 30px 40px;
|
||||
background: #1d2a5e; // Deep slate dark card
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-left: 5px solid #2a69de; // Bright tech blue accent line
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
gap: 28px;
|
||||
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
|
||||
&__icon {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: #48507b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
filter: brightness(0) invert(1); // Turns the green check into clean white icon
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
@@ -417,183 +422,397 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: #535151;
|
||||
font-size: 16px;
|
||||
padding-left: 20px;
|
||||
color: #94a3b8; // Sophisticated dim gray-blue
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.8;
|
||||
word-break: keep-all;
|
||||
|
||||
strong {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '·';
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #535151;
|
||||
left: 4px;
|
||||
top: 10px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: #38bdf8; // Neon cyan dot
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&+li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signup-timeline {
|
||||
// Member Type Tabs
|
||||
.signup-tabs {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
border-bottom: 1px solid #e3e8f0;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 50px;
|
||||
width: 100%;
|
||||
|
||||
.signup-tab-btn {
|
||||
flex: 1;
|
||||
max-width: 50%;
|
||||
padding: 16px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #2a69de;
|
||||
font-weight: 700;
|
||||
border-bottom: 2px solid #2a69de;
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signup-timeline-container {
|
||||
margin-bottom: 50px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
|
||||
.signup-timeline {
|
||||
display: none; // Hidden by default, toggled via JS
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
position: relative;
|
||||
padding-left: 80px;
|
||||
|
||||
&.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 17px; // center of line
|
||||
top: 47px;
|
||||
bottom: 47px;
|
||||
width: 2px;
|
||||
background: #e6eefb; // Light blue line
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
// Glowing Dot
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -71px; // (80 - 17) = 63 distance to line center. 63 + 8 (half of 16px) = 71px
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #2a69de;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 6px rgba(42, 105, 222, 0.15), 0 0 10px 6px rgba(42, 105, 222, 0.1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__icon-box {
|
||||
width: 95px;
|
||||
min-height: 95px;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e8f0;
|
||||
border-radius: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
|
||||
|
||||
svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e8f0;
|
||||
border-radius: 20px;
|
||||
padding: 20px 30px;
|
||||
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
position: relative;
|
||||
padding-left: 80px;
|
||||
justify-content: center;
|
||||
min-height: 95px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 17px; // center of line
|
||||
top: 47px;
|
||||
bottom: 47px;
|
||||
width: 2px;
|
||||
background: #e6eefb; // Light blue line
|
||||
z-index: 0;
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&-num {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1b4ab7;
|
||||
}
|
||||
|
||||
&-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #1b4ab7;
|
||||
border-radius: 50%;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
&-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-step {
|
||||
.one {
|
||||
color: #2C4A8C;
|
||||
}
|
||||
|
||||
.two {
|
||||
color: #1F7A5C;
|
||||
}
|
||||
|
||||
.three {
|
||||
color: #F98E24;
|
||||
}
|
||||
|
||||
.four {
|
||||
color: #2480F9;
|
||||
}
|
||||
|
||||
.five {
|
||||
color: #FF5C5C;
|
||||
}
|
||||
|
||||
.six {
|
||||
color: #5B4AEE;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
// 권한별 이용 가능 서비스 (TBD placeholder)
|
||||
.signup-roles-tbd {
|
||||
margin-top: 40px;
|
||||
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #140064;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
background: #f8f9fa;
|
||||
border: 1px dashed #e3e8f0;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #0049B4;
|
||||
background: #eff9fe;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #6e7781;
|
||||
}
|
||||
}
|
||||
|
||||
// 권한별 이용 가능 서비스 테이블 디자인 추가
|
||||
.signup-roles-table-section {
|
||||
margin-top: 50px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-left: 12px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
|
||||
// Glowing Dot
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -71px; // (80 - 17) = 63 distance to line center. 63 + 8 (half of 16px) = 71px
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #2a69de;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 6px rgba(42, 105, 222, 0.15), 0 0 10px 6px rgba(42, 105, 222, 0.1);
|
||||
z-index: 2;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 4px;
|
||||
background: #0049B4;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-roles-table-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&__icon-box {
|
||||
width: 95px;
|
||||
height: 95px;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e8f0;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
.signup-roles-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: $font-family-primary;
|
||||
text-align: center;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e8f0;
|
||||
border-radius: 20px;
|
||||
padding: 0 30px;
|
||||
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 95px;
|
||||
thead {
|
||||
background: #f8fafc;
|
||||
|
||||
&-num {
|
||||
th {
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
|
||||
&.col-feature {
|
||||
width: 35%;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&.col-role {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
&.col-role-group {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&.col-sub-role {
|
||||
width: 17.5%;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-feature {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-align: left;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
// O / X 배지 디자인
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1b4ab7;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&--allowed {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
&--denied {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.one {
|
||||
color: #2C4A8C;
|
||||
}
|
||||
|
||||
.two {
|
||||
color: #1F7A5C;
|
||||
}
|
||||
|
||||
.three {
|
||||
color: #F98E24;
|
||||
}
|
||||
|
||||
.four {
|
||||
color: #2480F9;
|
||||
}
|
||||
|
||||
.five {
|
||||
color: #FF5C5C;
|
||||
}
|
||||
|
||||
.six {
|
||||
color: #5B4AEE;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
// 권한별 이용 가능 서비스 (TBD placeholder)
|
||||
.signup-roles-tbd {
|
||||
margin-top: 40px;
|
||||
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #140064;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
background: #f8f9fa;
|
||||
border: 1px dashed #e3e8f0;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #0049B4;
|
||||
background: #eff9fe;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #6e7781;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -630,12 +849,25 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.signup-guide-v2 {
|
||||
.signup-timeline {
|
||||
padding-left: 0;
|
||||
|
||||
&::before {
|
||||
display: none; // Hides the vertical timeline line
|
||||
}
|
||||
}
|
||||
|
||||
.signup-step {
|
||||
gap: 15px;
|
||||
|
||||
&::before {
|
||||
display: none; // Hides the glowing timeline dot
|
||||
}
|
||||
|
||||
&__icon-box {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
min-height: 60px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
@@ -646,25 +878,13 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
&__card {
|
||||
height: auto;
|
||||
min-height: 60px;
|
||||
padding: 10px 15px;
|
||||
padding: 12px 18px;
|
||||
|
||||
&-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signup-timeline {
|
||||
padding-left: 50px;
|
||||
|
||||
&::before {
|
||||
left: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-step::before {
|
||||
left: -41px; // adjusted for new padding
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,23 +896,23 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
padding: 56px 16px 32px;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
}
|
||||
|
||||
.service_guide {
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 36px;
|
||||
letter-spacing: -0.02em;
|
||||
color: #140064;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.service_guide {
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 36px;
|
||||
letter-spacing: -0.02em;
|
||||
color: #140064;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: #495057;
|
||||
}
|
||||
.detail {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: #495057;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1117,9 +1337,10 @@ $o2leg-err-fg: #a23b3b;
|
||||
|
||||
&__prereq-title {
|
||||
margin: 4px 0 8px;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: $o2leg-text-dark;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
&__prereq-body p {
|
||||
|
||||
@@ -63,6 +63,7 @@ $wh-bg-soft: #f9f9f9;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 16px;
|
||||
color: #cbd5e1;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -653,7 +654,7 @@ $wh-bg-soft: #f9f9f9;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1.5px solid #4a4a4a;
|
||||
border-bottom: 1.5px solid #818181;
|
||||
|
||||
.head-title-group {
|
||||
display: flex;
|
||||
@@ -704,7 +705,7 @@ $wh-bg-soft: #f9f9f9;
|
||||
.input-display-box {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background-color: #eeeeee;
|
||||
background-color: #efefef;
|
||||
border-radius: 10px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
@@ -714,6 +715,7 @@ $wh-bg-soft: #f9f9f9;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #DFDFDF;
|
||||
}
|
||||
|
||||
.badges-row {
|
||||
@@ -767,7 +769,7 @@ $wh-bg-soft: #f9f9f9;
|
||||
flex: 1;
|
||||
max-width: 250px;
|
||||
height: 48px;
|
||||
background-color: #eeeeee;
|
||||
background-color: #efefef;
|
||||
border-radius: 10px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
@@ -778,6 +780,7 @@ $wh-bg-soft: #f9f9f9;
|
||||
font-weight: 500;
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 1px;
|
||||
border: 1px solid #DFDFDF;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -69,9 +69,18 @@
|
||||
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
|
||||
<!-- API Overview Card (Merged with Additional Information) -->
|
||||
<div class="api-overview-card">
|
||||
<!--/* 현재 상태는 게시된 장애·점검 공지 기준(/apistatus/current.json). 조회 실패해도 화면은 유지 */-->
|
||||
<div class="org-section-header org-section-header--agreement api-basic-info-header">
|
||||
<h3>기본 정보</h3>
|
||||
<button type="button" class="btn-action-primary md api-apply-btn">API 사용 신청</button>
|
||||
<div class="api-status-inline" id="apiStatusInline"
|
||||
th:attr="data-api-id=${apiSpecInfo.apiId},
|
||||
data-status-url=@{/apistatus/current.json}">
|
||||
<span class="api-status-inline__label">현재 상태</span>
|
||||
<span class="api-status-inline__badge is-loading" id="apiStatusBadge">조회 중</span>
|
||||
<!--/* apiName 은 표시용 - 이슈 이력 화면 필터는 apiId 로 동작한다 */-->
|
||||
<a class="btn-action-primary md"
|
||||
th:href="@{/apistatus/issues(apiId=${apiSpecInfo.apiId},apiName=${apiSpecInfo.apiName})}">상태 이력 확인</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="api-overview-header">
|
||||
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
|
||||
@@ -475,6 +484,51 @@
|
||||
btn.addEventListener('click', requestApiUse);
|
||||
});
|
||||
|
||||
// ===== 기본 정보: 현재 상태 =====
|
||||
// 게시된 장애·점검 공지 기준(/apistatus/current.json). 상태 이력은 /apistatus/issues?apiId= 로 이어진다.
|
||||
(function loadApiCurrentStatus() {
|
||||
const box = document.getElementById('apiStatusInline');
|
||||
const badge = document.getElementById('apiStatusBadge');
|
||||
if (!box || !badge) return;
|
||||
|
||||
const apiId = box.getAttribute('data-api-id');
|
||||
const statusUrl = box.getAttribute('data-status-url');
|
||||
if (!apiId || !statusUrl) return;
|
||||
|
||||
const BADGE_CLASS = {
|
||||
OUTAGE: 'is-outage',
|
||||
DEGRADED: 'is-degraded',
|
||||
MAINTENANCE: 'is-maintenance',
|
||||
NORMAL: 'is-normal'
|
||||
};
|
||||
|
||||
function paint(cls, text, tooltip) {
|
||||
badge.className = 'api-status-inline__badge ' + cls;
|
||||
badge.textContent = text;
|
||||
if (tooltip) badge.setAttribute('title', tooltip);
|
||||
else badge.removeAttribute('title');
|
||||
}
|
||||
|
||||
fetch(statusUrl + '?apiId=' + encodeURIComponent(apiId), { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (status) {
|
||||
const tooltip = [
|
||||
status.activeIncidentTitle,
|
||||
status.statusSince ? '시작 ' + status.statusSince : null,
|
||||
status.expectedEndAt ? '종료 예정 ' + status.expectedEndAt : null
|
||||
].filter(Boolean).join('\n');
|
||||
paint(BADGE_CLASS[status.currentStatus] || 'is-muted',
|
||||
status.currentStatusLabel || '정상', tooltip);
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error('API 현재 상태 조회 실패', e);
|
||||
paint('is-muted', '상태 확인 불가');
|
||||
});
|
||||
})();
|
||||
|
||||
// ===== DJPGPT0001: 앱 인증 정보 자동 주입 =====
|
||||
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
|
||||
const appsSelect = document.getElementById('apps');
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
@@ -75,13 +75,20 @@
|
||||
</div>
|
||||
|
||||
<!-- API Cards Grid -->
|
||||
<div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}">
|
||||
<div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}"
|
||||
th:attr="data-status-url=@{/apistatus/current-list.json}">
|
||||
<div class="api-card" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}" role="button"
|
||||
tabindex="0">
|
||||
|
||||
<!-- Group Badge -->
|
||||
<div class="api-card-badge">
|
||||
<span th:text="${api.apiGroupName}">그룹 이름</span>
|
||||
<!-- Group Badge + 현재 상태 태그 -->
|
||||
<div class="api-card-badges">
|
||||
<div class="api-card-badge">
|
||||
<span th:text="${api.apiGroupName}">그룹 이름</span>
|
||||
</div>
|
||||
<!--/* 상태 태그 노출은 PTL_PROPERTY(djb.apistatus.api-list-status-badge) 로 제어.
|
||||
값은 로드 후 JS가 채우며, 비어 있는 동안은 CSS(:empty)로 숨긴다 */-->
|
||||
<span th:if="${apiStatusBadgeEnabled}" class="api-card-status"
|
||||
th:attr="data-api-id=${api.apiId}"></span>
|
||||
</div>
|
||||
|
||||
<!-- API Name -->
|
||||
@@ -152,6 +159,62 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 카드 현재 상태 태그 =====
|
||||
// 게시된 장애·점검 공지 기준(/apistatus/current-list.json). 태그 자체는 PTL_PROPERTY 로 노출 제어된다.
|
||||
(function loadApiCardStatuses() {
|
||||
const grid = document.querySelector('.api-card-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const statusUrl = grid.getAttribute('data-status-url');
|
||||
const slots = Array.prototype.slice.call(grid.querySelectorAll('.api-card-status[data-api-id]'));
|
||||
if (!statusUrl || !slots.length) return;
|
||||
|
||||
const BADGE_CLASS = {
|
||||
OUTAGE: 'is-outage',
|
||||
DEGRADED: 'is-degraded',
|
||||
MAINTENANCE: 'is-maintenance',
|
||||
NORMAL: 'is-normal'
|
||||
};
|
||||
const CHUNK = 50; // 서버 일괄 조회 상한(100) 이내로 나눠 요청
|
||||
|
||||
// 같은 API 가 여러 카드에 나올 수 있어 apiId → 슬롯 목록으로 묶는다
|
||||
const slotsByApiId = {};
|
||||
slots.forEach(function (el) {
|
||||
const apiId = el.getAttribute('data-api-id');
|
||||
if (!apiId) return;
|
||||
(slotsByApiId[apiId] = slotsByApiId[apiId] || []).push(el);
|
||||
});
|
||||
const apiIds = Object.keys(slotsByApiId);
|
||||
|
||||
function paint(list) {
|
||||
(list || []).forEach(function (status) {
|
||||
const targets = slotsByApiId[status.apiId];
|
||||
const cls = BADGE_CLASS[status.currentStatus];
|
||||
if (!targets || !cls) return;
|
||||
targets.forEach(function (el) {
|
||||
el.className = 'api-card-status ' + cls;
|
||||
el.textContent = status.currentStatusLabel || '';
|
||||
if (status.activeIncidentTitle) el.setAttribute('title', status.activeIncidentTitle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < apiIds.length; index += CHUNK) {
|
||||
const query = apiIds.slice(index, index + CHUNK).map(function (apiId) {
|
||||
return 'apiId=' + encodeURIComponent(apiId);
|
||||
}).join('&');
|
||||
|
||||
fetch(statusUrl + '?' + query, { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(paint)
|
||||
// 상태 조회 실패는 목록 자체를 막지 않는다 (태그는 비어 있는 채로 숨겨진다)
|
||||
.catch(function (e) { console.error('API 현재 상태 조회 실패', e); });
|
||||
}
|
||||
})();
|
||||
|
||||
// Category/Service Selection
|
||||
const menuTitles = document.querySelectorAll('.js-menu-trigger');
|
||||
menuTitles.forEach(function (title) {
|
||||
|
||||
@@ -1,61 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>본인 확인</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">본인 확인</h2>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>본인 확인</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">본인 확인</h2>
|
||||
|
||||
<form th:action="@{/auth/stepup/password}" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<input type="hidden" name="returnUrl" th:value="${returnUrl}"/>
|
||||
<form th:action="@{/auth/stepup/password}" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
|
||||
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">현재 비밀번호</span>
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" name="currentPassword" class="form-input"
|
||||
placeholder="비밀번호 입력" required autofocus>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">현재 비밀번호</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" name="currentPassword" class="form-input"
|
||||
placeholder="비밀번호 입력" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="justify-content: flex-end;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="submit" class="btn-apply btn-primary">확인</button>
|
||||
<div class="form-actions" style="justify-content: flex-end;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="submit" class="btn-apply btn-primary">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${error}]]);
|
||||
})
|
||||
</script>
|
||||
</section>
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${ error }]]);
|
||||
})
|
||||
</script>
|
||||
</section>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -16,7 +16,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘"
|
||||
<img th:src="@{/img/keyimage/faq_img.png}" alt="FAQ 아이콘"
|
||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<!-- TODO: Update icon to faq icon -->
|
||||
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘"
|
||||
<img th:src="@{/img/keyimage/faq_img.png}" alt="FAQ 아이콘"
|
||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
|
||||
@@ -76,9 +76,9 @@
|
||||
<!-- Table Header -->
|
||||
<div class="board-table-header">
|
||||
<div class="header-cell" style="width: 80px;">NO</div>
|
||||
<div class="header-cell" style="width: 120px;">처리상태</div>
|
||||
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
|
||||
<div class="header-cell" style="width: 120px;">작성자</div>
|
||||
<div class="header-cell" style="width: 120px;">처리상태</div>
|
||||
<div class="header-cell" style="width: 80px;">조회수</div>
|
||||
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||
</div>
|
||||
@@ -89,9 +89,19 @@
|
||||
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
|
||||
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
|
||||
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
|
||||
<div class="row-cell row-cell--number" data-label="NO"
|
||||
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
|
||||
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
||||
<div class="row-cell row-cell--title" data-label="제목">
|
||||
|
||||
<!-- 처리상태 (Moved here) -->
|
||||
<div class="row-cell row-cell--status" style="width: 120px;" data-label="처리상태">
|
||||
<span class="notice-type-badge"
|
||||
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'notice-type-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'notice-type-badge--closed' : (inquiry.inquiryStatus == 'REVIEWING' ? 'notice-type-badge--reviewing' : 'notice-type-badge--pending'))}"
|
||||
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
||||
답변대기
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
|
||||
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
|
||||
class="notice-title-link"
|
||||
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
|
||||
@@ -99,7 +109,7 @@
|
||||
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||
|
||||
<!-- Private/Lock Icon -->
|
||||
<span class="file-icon" th:if="${inquiry.visibility == 'PRIVATE'}">
|
||||
<span class="file-icon" th:if="${inquiry.visibility == 'PRIVATE'}" style="margin-right: 4px;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="비공개">
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
|
||||
@@ -117,22 +127,14 @@
|
||||
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row-cell row-cell--writer" data-label="작성자">
|
||||
<div class="row-cell row-cell--writer" style="width: 120px;" data-label="작성자">
|
||||
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
|
||||
<span class="inquiry-writer-org"
|
||||
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
|
||||
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
|
||||
</div>
|
||||
<div class="row-cell row-cell--status" data-label="처리상태">
|
||||
<!-- completed for RESPONDED, closed for CLOSED, pending for PENDING, maintenance for REVIEWING -->
|
||||
<span class="notice-type-badge lg"
|
||||
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'notice-type-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'notice-type-badge--closed' : (inquiry.inquiryStatus == 'REVIEWING' ? 'notice-type-badge--maintenance' : 'notice-type-badge--pending'))}"
|
||||
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
||||
답변대기
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-cell row-cell--views" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
||||
<div class="row-cell row-cell--date" data-label="등록일"
|
||||
<div class="row-cell row-cell--views" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
||||
<div class="row-cell row-cell--date" style="width: 120px;" data-label="등록일"
|
||||
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
|
||||
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘"
|
||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
@@ -69,7 +69,7 @@
|
||||
<td
|
||||
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">
|
||||
-</td>
|
||||
<th>끝</th>
|
||||
<th>종료</th>
|
||||
<td
|
||||
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">
|
||||
-</td>
|
||||
@@ -78,29 +78,27 @@
|
||||
<th>상태</th>
|
||||
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
|
||||
</tr>
|
||||
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
|
||||
<tr
|
||||
th:if="${(portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()) or portalNotice.hiddenApiCount > 0}">
|
||||
<th>영향 API</th>
|
||||
<td colspan="3">
|
||||
<div class="affected-apis-wrapper">
|
||||
<!-- First 3 APIs (Always visible) -->
|
||||
<!-- 개발자포탈에 게시된 API 만 개별 노출한다 (인터페이스 ID 는 표시하지 않음) -->
|
||||
<div class="affected-apis-list">
|
||||
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
|
||||
th:if="${iterStat.index < 3}">
|
||||
<strong th:text="${api.apiId}">API_ID</strong><span
|
||||
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
||||
th:text="| - ${api.apiName}|"></span>
|
||||
</span>
|
||||
th:if="${iterStat.index < 3}" th:text="${api.apiName}">API 명</span>
|
||||
|
||||
<!-- Rest of APIs (Hidden by default) -->
|
||||
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
|
||||
<span class="affected-api-badge extra-api"
|
||||
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
|
||||
style="display: none;">
|
||||
<strong th:text="${api.apiId}">API_ID</strong><span
|
||||
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
||||
th:text="| - ${api.apiName}|"></span>
|
||||
</span>
|
||||
style="display: none;" th:text="${api.apiName}">API 명</span>
|
||||
</th:block>
|
||||
|
||||
<!-- 게시되지 않은 GW 인터페이스는 건수로만 묶어서 표기 -->
|
||||
<span class="affected-api-badge is-gw" th:if="${portalNotice.hiddenApiCount > 0}"
|
||||
th:text="|GW 인터페이스 ${portalNotice.hiddenApiCount}건|"
|
||||
title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">GW 인터페이스 0건</span>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Button -->
|
||||
@@ -141,6 +139,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 장애 처리 타임라인 (공개 항목이 있을 때만) -->
|
||||
<div class="notice-detail-timeline" th:if="${portalNotice.hasTimeline()}">
|
||||
<h3 class="notice-timeline-title">처리 경과</h3>
|
||||
<table class="detail-table timeline-table">
|
||||
<colgroup>
|
||||
<col style="width: 160px;">
|
||||
<col style="width: 110px;">
|
||||
<col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일시</th>
|
||||
<th>상태</th>
|
||||
<th>내용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="entry : ${portalNotice.timeline}">
|
||||
<td class="timeline-ts"
|
||||
th:text="${entry.eventAt != null ? #temporals.format(entry.eventAt, 'yyyy-MM-dd HH:mm') : '-'}">
|
||||
2026-08-04 11:44</td>
|
||||
<td>
|
||||
<span class="timeline-state"
|
||||
th:classappend="${'state-' + (entry.stateAfter != null ? entry.stateAfter : 'NONE')}"
|
||||
th:text="${entry.labelKo != null ? entry.labelKo : '진행 상황'}">원인 확인</span>
|
||||
</td>
|
||||
<td class="timeline-body" th:text="${entry.body}">진행 내용</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="notice-detail-actions">
|
||||
<button type="button" class="btn-action-primary btn-notice-list"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
@@ -84,7 +84,13 @@
|
||||
<a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link">
|
||||
<span class="notice-number"
|
||||
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
|
||||
<span class="notice-pin-icon" th:if="${notice.fixYn == 'Y'}"
|
||||
style="margin-right: 6px; display: inline-flex; align-items: center; vertical-align: middle; color: #ef4444;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 12V4H17V2H7V4H8V12L6 14V16H11V22L12 23L13 22V16H18V14L16 12Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="notice-type-badge notice-type-badge--incident"
|
||||
th:if="${notice.noticeType == '3'}">장애</span>
|
||||
<span class="notice-type-badge notice-type-badge--maintenance"
|
||||
|
||||
@@ -1,66 +1,73 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>아이디 찾기</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="account-recovery-page">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="account-recovery-tabs">
|
||||
<a href="#" class="tab-link active">아이디찾기</a>
|
||||
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
|
||||
</div>
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<!-- Result Content Area -->
|
||||
<div class="account-recovery-result">
|
||||
<div class="result-header">
|
||||
<i class="fas fa-check-circle result-icon"></i>
|
||||
<h2 class="result-title">회원님의 아이디를 찾았습니다.</h2>
|
||||
<p class="result-description" th:if="${#lists.size(foundUsers) > 1}">
|
||||
동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다.
|
||||
</p>
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>아이디 찾기</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="account-recovery-page">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="account-recovery-tabs">
|
||||
<a href="#" class="tab-link active">아이디찾기</a>
|
||||
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
|
||||
</div>
|
||||
|
||||
<!-- Found Users List -->
|
||||
<div class="found-users-list">
|
||||
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
|
||||
<div class="user-info">
|
||||
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
|
||||
<span class="user-date">
|
||||
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
|
||||
</span>
|
||||
<!-- Result Content Area -->
|
||||
<div class="account-recovery-result">
|
||||
<div class="result-header">
|
||||
<i class="fas fa-check-circle result-icon"></i>
|
||||
<h2 class="result-title">회원님의 아이디를 찾았습니다.</h2>
|
||||
<p class="result-description" th:if="${#lists.size(foundUsers) > 1}">
|
||||
동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Found Users List -->
|
||||
<div class="found-users-list">
|
||||
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
|
||||
<div class="user-info">
|
||||
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
|
||||
<span class="user-date">
|
||||
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="result-info-box">
|
||||
<p class="info-text">
|
||||
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
비밀번호가 기억나지 않는 경우에는
|
||||
<a th:href="@{/account_recovery(tab='resetPassword')}" class="info-link">비밀번호 초기화</a>를
|
||||
이용해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="result-info-box">
|
||||
<p class="info-text">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
비밀번호가 기억나지 않는 경우에는
|
||||
<a th:href="@{/account_recovery(tab='resetPassword')}" class="info-link">비밀번호 초기화</a>를
|
||||
이용해 주세요.
|
||||
</p>
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<a th:href="@{/account_recovery(tab='findId')}" class="cancel-button">다시 찾기</a>
|
||||
<a th:href="@{/login}" class="submit-button">로그인</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<a th:href="@{/account_recovery(tab='findId')}" class="cancel-button">다시 찾기</a>
|
||||
<a th:href="@{/login}" class="submit-button">로그인</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</body>
|
||||
<th:block layout:fragment="contentScript">
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
@@ -17,7 +17,7 @@
|
||||
<p class="hero-subtitle">세상의 모든 서비스</p>
|
||||
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
|
||||
</div>
|
||||
<a href="#" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
|
||||
<a th:href="@{/service/guide}" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
|
||||
</div>
|
||||
<div class="hero-image-content">
|
||||
<!-- Inline SVG Tech Illustration -->
|
||||
@@ -47,7 +47,8 @@
|
||||
<p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p>
|
||||
<h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2>
|
||||
</div>
|
||||
<a href="#" class="btn-hero-signup">개발 가이드 보기 <i class="bi bi-chevron-right"></i></a>
|
||||
<a th:href="@{/service/oauth2-guide}" class="btn-hero-signup">개발 가이드 보기 <i
|
||||
class="bi bi-chevron-right"></i></a>
|
||||
</div>
|
||||
<div class="hero-image-content">
|
||||
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -72,7 +73,7 @@
|
||||
<p class="hero-subtitle">상상하는 금융의 구현</p>
|
||||
<h2 class="hero-title">기업의 비즈니스를<br>혁신합니다.</h2>
|
||||
</div>
|
||||
<a href="#" class="btn-hero-signup">제휴 문의하기 <i class="bi bi-chevron-right"></i></a>
|
||||
<a th:href="@{/partnership}" class="btn-hero-signup">제휴 문의하기 <i class="bi bi-chevron-right"></i></a>
|
||||
</div>
|
||||
<div class="hero-image-content">
|
||||
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -146,40 +147,6 @@
|
||||
<!-- API 검색 섹션 -->
|
||||
<section class="api-search-section">
|
||||
<div class="search-background"></div>
|
||||
<!--/* <div class="search-content-wrapper">*/-->
|
||||
<!--/* <div class="search-character">*/-->
|
||||
<!--/* <img th:src="@{/img/img_search_character.png}" alt="검색">*/-->
|
||||
<!--/* </div>*/-->
|
||||
<!--/* <div class="search-text-content">*/-->
|
||||
<!--/* <h2 class="search-title">*/-->
|
||||
<!--/* 원하는 API를<br>*/-->
|
||||
<!-- 지금 검색해 보세요.-->
|
||||
<!--/* </h2>*/-->
|
||||
<!--/* </div>*/-->
|
||||
<!--/* </div>*/-->
|
||||
<!--/* <div class="search-input-wrapper">*/-->
|
||||
<!--/* <form th:action="@{/apis}" method="get" class="search-form">*/-->
|
||||
<!--/* <div class="search-box">*/-->
|
||||
<!--/* <input type="text" class="search-input" placeholder="어떤 API를 칮고 계신가요?" id="apiSearchInput" name="keyword">*/-->
|
||||
<!--/* <button class="search-icon-button" type="submit">*/-->
|
||||
<!--/* <svg width="33" height="34" viewBox="0 0 33 34" fill="none" xmlns="http://www.w3.org/2000/svg">*/-->
|
||||
<!--/* <path d="M15.1738 0C23.5534 0.000258465 30.3465 6.79327 30.3467 15.1729C30.3467 18.6141 29.2001 21.7875 27.2695 24.333C27.2733 24.3367 27.2775 24.34 27.2812 24.3438L32.1348 29.1973C33.1223 30.1849 33.1223 31.7859 32.1348 32.7734C31.1472 33.7609 29.5461 33.7609 28.5586 32.7734L23.7051 27.9199C23.6653 27.8802 23.6293 27.8376 23.5928 27.7959C21.1837 29.406 18.2889 30.3466 15.1738 30.3467C6.79392 30.3467 0 23.5528 0 15.1729C0.000201956 6.79311 6.79404 0 15.1738 0ZM15.1738 5.05762C9.58735 5.05762 5.05782 9.58642 5.05762 15.1729C5.05762 20.7595 9.58722 25.2891 15.1738 25.2891C20.7602 25.2888 25.2891 20.7593 25.2891 15.1729C25.2889 9.58658 20.7601 5.05788 15.1738 5.05762Z" fill="#0049B4"/>*/-->
|
||||
<!--/* </svg>*/-->
|
||||
<!--/* </button>*/-->
|
||||
<!--/* </div>*/-->
|
||||
<!--/* </form>*/-->
|
||||
<!--/* </div>*/-->
|
||||
<!--/* <div class="hashtag-section">*/-->
|
||||
<!--/* <span class="hashtag-label">자주찾는 API</span>*/-->
|
||||
<!--/* <div class="hashtag-list" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">*/-->
|
||||
<!--/* <th:block th:each="hashtag, iterStat : ${hashtags}">*/-->
|
||||
<!--/* <a href="#" class="hashtag-link" th:data-search="${hashtag}" th:text="${'#' + hashtag}">*/-->
|
||||
<!--/* </a>*/-->
|
||||
<!--/* <span class="hashtag-separator" th:unless="${iterStat.last}">|</span>*/-->
|
||||
<!--/* </th:block>*/-->
|
||||
<!--/* </div>*/-->
|
||||
<!--/* </div>*/-->
|
||||
|
||||
|
||||
|
||||
<!-- 피그마 시안 적용 카드 레이아웃 -->
|
||||
@@ -259,8 +226,8 @@
|
||||
</div>
|
||||
|
||||
<div class="api-cards-container">
|
||||
<!-- Services 데이터를 반복하여 API 카드 표시 (최대 4개) -->
|
||||
<div class="api-card" th:each="service, iterStat : ${services}" th:if="${iterStat.index < 4}"
|
||||
<!-- main.service.list 에 등록된 서비스 전체 표시 (1행 4열, 초과분은 다음 행으로) -->
|
||||
<div class="api-card" th:each="service : ${services}"
|
||||
th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|">
|
||||
<h3 class="card-title" th:text="${service.groupName}">API 서비스</h3>
|
||||
<p class="card-description">
|
||||
@@ -328,7 +295,8 @@
|
||||
</p>
|
||||
<div class="action-buttons">
|
||||
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
|
||||
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
|
||||
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i
|
||||
class="bi bi-patch-question"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-image-box">
|
||||
@@ -597,268 +565,284 @@
|
||||
|
||||
<!-- 로그인 후 처리 스크립트 -->
|
||||
<script th:src="@{/js/login-success-handler.js}"></script>
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
LoginSuccessHandler.init({
|
||||
successMsg: [[${ success }]],
|
||||
emailVerificationRequired: [[${ session.emailVerificationRequired }]],
|
||||
dormantAccount: [[${ session.dormantAccount }]],
|
||||
passwordExpired: [[${ session.passwordExpired }]],
|
||||
pendingInvitation: [[${ session.pendingInvitation }]],
|
||||
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
|
||||
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
|
||||
needClientRegister: [[${ needClientRegister }]],
|
||||
sessionSuccessMsg: [[${ session.success }]],
|
||||
redirectUrl: [[${ session.redirectUrl }]]
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// ========================================
|
||||
// Hero Carousel
|
||||
// ========================================
|
||||
const heroCarousel = {
|
||||
currentSlide: 0,
|
||||
totalSlides: 3,
|
||||
autoPlayInterval: null,
|
||||
autoPlayDelay: 5000,
|
||||
isPlaying: true,
|
||||
|
||||
init: function () {
|
||||
this.slides = document.querySelectorAll('.hero-slide');
|
||||
this.indicators = document.querySelectorAll('.hero-indicator');
|
||||
this.prevBtn = document.getElementById('heroPrevBtn');
|
||||
this.nextBtn = document.getElementById('heroNextBtn');
|
||||
this.autoplayToggle = document.getElementById('heroAutoplayToggle');
|
||||
this.pauseIcon = this.autoplayToggle?.querySelector('.pause-icon');
|
||||
this.playIcon = this.autoplayToggle?.querySelector('.play-icon');
|
||||
|
||||
if (!this.slides.length) return;
|
||||
|
||||
this.bindEvents();
|
||||
this.startAutoPlay();
|
||||
},
|
||||
|
||||
bindEvents: function () {
|
||||
// Previous button
|
||||
if (this.prevBtn) {
|
||||
this.prevBtn.addEventListener('click', () => {
|
||||
this.goToSlide(this.currentSlide - 1);
|
||||
this.resetAutoPlay();
|
||||
});
|
||||
}
|
||||
|
||||
// Next button
|
||||
if (this.nextBtn) {
|
||||
this.nextBtn.addEventListener('click', () => {
|
||||
this.goToSlide(this.currentSlide + 1);
|
||||
this.resetAutoPlay();
|
||||
});
|
||||
}
|
||||
|
||||
// Indicators
|
||||
this.indicators.forEach((indicator, index) => {
|
||||
indicator.addEventListener('click', () => {
|
||||
this.goToSlide(index);
|
||||
this.resetAutoPlay();
|
||||
});
|
||||
});
|
||||
|
||||
// Auto play toggle button
|
||||
if (this.autoplayToggle) {
|
||||
this.autoplayToggle.addEventListener('click', () => {
|
||||
this.toggleAutoPlay();
|
||||
});
|
||||
}
|
||||
|
||||
// Pause on hover (only if playing)
|
||||
const container = document.querySelector('.hero-carousel-container');
|
||||
if (container) {
|
||||
container.addEventListener('mouseenter', () => {
|
||||
if (this.isPlaying) {
|
||||
this.pauseAutoPlay();
|
||||
}
|
||||
});
|
||||
container.addEventListener('mouseleave', () => {
|
||||
if (this.isPlaying) {
|
||||
this.startAutoPlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Touch events for mobile
|
||||
let touchStartX = 0;
|
||||
let touchEndX = 0;
|
||||
|
||||
if (container) {
|
||||
container.addEventListener('touchstart', (e) => {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
});
|
||||
|
||||
container.addEventListener('touchend', (e) => {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
this.handleSwipe(touchStartX, touchEndX);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
goToSlide: function (index) {
|
||||
// Wrap around
|
||||
if (index < 0) {
|
||||
index = this.totalSlides - 1;
|
||||
} else if (index >= this.totalSlides) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
// Update slides
|
||||
this.slides.forEach((slide, i) => {
|
||||
slide.classList.toggle('active', i === index);
|
||||
});
|
||||
|
||||
// Update indicators
|
||||
this.indicators.forEach((indicator, i) => {
|
||||
indicator.classList.toggle('active', i === index);
|
||||
});
|
||||
|
||||
this.currentSlide = index;
|
||||
},
|
||||
|
||||
handleSwipe: function (startX, endX) {
|
||||
const minSwipeDistance = 50;
|
||||
const distance = startX - endX;
|
||||
|
||||
if (Math.abs(distance) > minSwipeDistance) {
|
||||
if (distance > 0) {
|
||||
// Swipe left - next slide
|
||||
this.goToSlide(this.currentSlide + 1);
|
||||
} else {
|
||||
// Swipe right - previous slide
|
||||
this.goToSlide(this.currentSlide - 1);
|
||||
}
|
||||
this.resetAutoPlay();
|
||||
}
|
||||
},
|
||||
|
||||
startAutoPlay: function () {
|
||||
this.pauseAutoPlay();
|
||||
this.autoPlayInterval = setInterval(() => {
|
||||
this.goToSlide(this.currentSlide + 1);
|
||||
}, this.autoPlayDelay);
|
||||
},
|
||||
|
||||
pauseAutoPlay: function () {
|
||||
if (this.autoPlayInterval) {
|
||||
clearInterval(this.autoPlayInterval);
|
||||
this.autoPlayInterval = null;
|
||||
}
|
||||
},
|
||||
|
||||
resetAutoPlay: function () {
|
||||
if (this.isPlaying) {
|
||||
this.pauseAutoPlay();
|
||||
this.startAutoPlay();
|
||||
}
|
||||
},
|
||||
|
||||
toggleAutoPlay: function () {
|
||||
this.isPlaying = !this.isPlaying;
|
||||
|
||||
if (this.isPlaying) {
|
||||
// Start auto play
|
||||
this.startAutoPlay();
|
||||
// Update icons
|
||||
if (this.pauseIcon) this.pauseIcon.classList.add('active');
|
||||
if (this.playIcon) this.playIcon.classList.remove('active');
|
||||
// Update aria-label
|
||||
if (this.autoplayToggle) {
|
||||
this.autoplayToggle.setAttribute('aria-label', '자동 재생 일시정지');
|
||||
}
|
||||
} else {
|
||||
// Stop auto play
|
||||
this.pauseAutoPlay();
|
||||
// Update icons
|
||||
if (this.pauseIcon) this.pauseIcon.classList.remove('active');
|
||||
if (this.playIcon) this.playIcon.classList.add('active');
|
||||
// Update aria-label
|
||||
if (this.autoplayToggle) {
|
||||
this.autoplayToggle.setAttribute('aria-label', '자동 재생 시작');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize hero carousel
|
||||
heroCarousel.init();
|
||||
|
||||
// ========================================
|
||||
// API 검색 기능
|
||||
// ========================================
|
||||
const searchInput = document.getElementById('apiSearchInput');
|
||||
const searchForm = document.querySelector('.search-form');
|
||||
const hashtags = document.querySelectorAll('.hashtag-link');
|
||||
|
||||
// 해시태그 클릭 이벤트
|
||||
hashtags.forEach(tag => {
|
||||
tag.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const searchTerm = this.getAttribute('data-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = searchTerm;
|
||||
}
|
||||
// 폼 제출
|
||||
if (searchForm) {
|
||||
searchForm.submit();
|
||||
}
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
LoginSuccessHandler.init({
|
||||
successMsg: [[${ success }]],
|
||||
emailVerificationRequired: [[${ session.emailVerificationRequired }]],
|
||||
dormantAccount: [[${ session.dormantAccount }]],
|
||||
passwordExpired: [[${ session.passwordExpired }]],
|
||||
pendingInvitation: [[${ session.pendingInvitation }]],
|
||||
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
|
||||
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
|
||||
needClientRegister: [[${ needClientRegister }]],
|
||||
sessionSuccessMsg: [[${ session.success }]],
|
||||
redirectUrl: [[${ session.redirectUrl }]]
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
// ========================================
|
||||
// 숫자 카운트업 애니메이션
|
||||
// ========================================
|
||||
const observerOptions = {
|
||||
threshold: 0.5,
|
||||
rootMargin: '0px 0px -100px 0px'
|
||||
};
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// ========================================
|
||||
// Hero Carousel
|
||||
// ========================================
|
||||
const heroCarousel = {
|
||||
currentSlide: 0,
|
||||
totalSlides: 3,
|
||||
autoPlayInterval: null,
|
||||
autoPlayDelay: 5000,
|
||||
isPlaying: true,
|
||||
|
||||
const animateNumbers = (entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const numbers = entry.target.querySelectorAll('.stat-number');
|
||||
init: function () {
|
||||
this.slides = document.querySelectorAll('.hero-slide');
|
||||
this.indicators = document.querySelectorAll('.hero-indicator');
|
||||
this.prevBtn = document.getElementById('heroPrevBtn');
|
||||
this.nextBtn = document.getElementById('heroNextBtn');
|
||||
this.autoplayToggle = document.getElementById('heroAutoplayToggle');
|
||||
this.pauseIcon = this.autoplayToggle?.querySelector('.pause-icon');
|
||||
this.playIcon = this.autoplayToggle?.querySelector('.play-icon');
|
||||
|
||||
numbers.forEach(number => {
|
||||
const target = parseInt(number.getAttribute('data-target'));
|
||||
const duration = 2000; // 2초
|
||||
const increment = target / (duration / 16);
|
||||
let current = 0;
|
||||
if (!this.slides.length) return;
|
||||
|
||||
const updateNumber = () => {
|
||||
current += increment;
|
||||
if (current < target) {
|
||||
number.textContent = Math.floor(current).toLocaleString('ko-KR');
|
||||
requestAnimationFrame(updateNumber);
|
||||
} else {
|
||||
number.textContent = target.toLocaleString('ko-KR');
|
||||
this.bindEvents();
|
||||
this.startAutoPlay();
|
||||
},
|
||||
|
||||
bindEvents: function () {
|
||||
// Previous button
|
||||
if (this.prevBtn) {
|
||||
this.prevBtn.addEventListener('click', () => {
|
||||
this.goToSlide(this.currentSlide - 1);
|
||||
this.resetAutoPlay();
|
||||
});
|
||||
}
|
||||
|
||||
// Next button
|
||||
if (this.nextBtn) {
|
||||
this.nextBtn.addEventListener('click', () => {
|
||||
this.goToSlide(this.currentSlide + 1);
|
||||
this.resetAutoPlay();
|
||||
});
|
||||
}
|
||||
|
||||
// Indicators
|
||||
this.indicators.forEach((indicator, index) => {
|
||||
indicator.addEventListener('click', () => {
|
||||
this.goToSlide(index);
|
||||
this.resetAutoPlay();
|
||||
});
|
||||
});
|
||||
|
||||
// Auto play toggle button
|
||||
if (this.autoplayToggle) {
|
||||
this.autoplayToggle.addEventListener('click', () => {
|
||||
this.toggleAutoPlay();
|
||||
});
|
||||
}
|
||||
|
||||
// Pause on hover (only if playing)
|
||||
const container = document.querySelector('.hero-carousel-container');
|
||||
if (container) {
|
||||
container.addEventListener('mouseenter', () => {
|
||||
if (this.isPlaying) {
|
||||
this.pauseAutoPlay();
|
||||
}
|
||||
};
|
||||
});
|
||||
container.addEventListener('mouseleave', () => {
|
||||
if (this.isPlaying) {
|
||||
this.startAutoPlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateNumber();
|
||||
// Touch events for mobile
|
||||
let touchStartX = 0;
|
||||
let touchEndX = 0;
|
||||
|
||||
if (container) {
|
||||
container.addEventListener('touchstart', (e) => {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
});
|
||||
|
||||
container.addEventListener('touchend', (e) => {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
this.handleSwipe(touchStartX, touchEndX);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
goToSlide: function (index) {
|
||||
// Wrap around
|
||||
if (index < 0) {
|
||||
index = this.totalSlides - 1;
|
||||
} else if (index >= this.totalSlides) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
// Update slides
|
||||
this.slides.forEach((slide, i) => {
|
||||
slide.classList.toggle('active', i === index);
|
||||
});
|
||||
|
||||
observer.unobserve(entry.target);
|
||||
// Update indicators
|
||||
this.indicators.forEach((indicator, i) => {
|
||||
indicator.classList.toggle('active', i === index);
|
||||
});
|
||||
|
||||
// Retrigger draw-line and pulsing animations for Slide 3 (index 2)
|
||||
if (index === 2) {
|
||||
const drawLinePath = this.slides[2].querySelector('.draw-line');
|
||||
const pulsingCircle = this.slides[2].querySelector('.pulsing');
|
||||
if (drawLinePath) {
|
||||
drawLinePath.style.animation = 'none';
|
||||
drawLinePath.offsetHeight;
|
||||
drawLinePath.style.animation = null;
|
||||
}
|
||||
if (pulsingCircle) {
|
||||
pulsingCircle.style.animation = 'none';
|
||||
pulsingCircle.offsetHeight;
|
||||
pulsingCircle.style.animation = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentSlide = index;
|
||||
},
|
||||
|
||||
handleSwipe: function (startX, endX) {
|
||||
const minSwipeDistance = 50;
|
||||
const distance = startX - endX;
|
||||
|
||||
if (Math.abs(distance) > minSwipeDistance) {
|
||||
if (distance > 0) {
|
||||
// Swipe left - next slide
|
||||
this.goToSlide(this.currentSlide + 1);
|
||||
} else {
|
||||
// Swipe right - previous slide
|
||||
this.goToSlide(this.currentSlide - 1);
|
||||
}
|
||||
this.resetAutoPlay();
|
||||
}
|
||||
},
|
||||
|
||||
startAutoPlay: function () {
|
||||
this.pauseAutoPlay();
|
||||
this.autoPlayInterval = setInterval(() => {
|
||||
this.goToSlide(this.currentSlide + 1);
|
||||
}, this.autoPlayDelay);
|
||||
},
|
||||
|
||||
pauseAutoPlay: function () {
|
||||
if (this.autoPlayInterval) {
|
||||
clearInterval(this.autoPlayInterval);
|
||||
this.autoPlayInterval = null;
|
||||
}
|
||||
},
|
||||
|
||||
resetAutoPlay: function () {
|
||||
if (this.isPlaying) {
|
||||
this.pauseAutoPlay();
|
||||
this.startAutoPlay();
|
||||
}
|
||||
},
|
||||
|
||||
toggleAutoPlay: function () {
|
||||
this.isPlaying = !this.isPlaying;
|
||||
|
||||
if (this.isPlaying) {
|
||||
// Start auto play
|
||||
this.startAutoPlay();
|
||||
// Update icons
|
||||
if (this.pauseIcon) this.pauseIcon.classList.add('active');
|
||||
if (this.playIcon) this.playIcon.classList.remove('active');
|
||||
// Update aria-label
|
||||
if (this.autoplayToggle) {
|
||||
this.autoplayToggle.setAttribute('aria-label', '자동 재생 일시정지');
|
||||
}
|
||||
} else {
|
||||
// Stop auto play
|
||||
this.pauseAutoPlay();
|
||||
// Update icons
|
||||
if (this.pauseIcon) this.pauseIcon.classList.remove('active');
|
||||
if (this.playIcon) this.playIcon.classList.add('active');
|
||||
// Update aria-label
|
||||
if (this.autoplayToggle) {
|
||||
this.autoplayToggle.setAttribute('aria-label', '자동 재생 시작');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize hero carousel
|
||||
heroCarousel.init();
|
||||
|
||||
// ========================================
|
||||
// API 검색 기능
|
||||
// ========================================
|
||||
const searchInput = document.getElementById('apiSearchInput');
|
||||
const searchForm = document.querySelector('.search-form');
|
||||
const hashtags = document.querySelectorAll('.hashtag-link');
|
||||
|
||||
// 해시태그 클릭 이벤트
|
||||
hashtags.forEach(tag => {
|
||||
tag.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const searchTerm = this.getAttribute('data-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = searchTerm;
|
||||
}
|
||||
// 폼 제출
|
||||
if (searchForm) {
|
||||
searchForm.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(animateNumbers, observerOptions);
|
||||
const statsSection = document.querySelector('.api-stats-section');
|
||||
// ========================================
|
||||
// 숫자 카운트업 애니메이션
|
||||
// ========================================
|
||||
const observerOptions = {
|
||||
threshold: 0.5,
|
||||
rootMargin: '0px 0px -100px 0px'
|
||||
};
|
||||
|
||||
if (statsSection) {
|
||||
observer.observe(statsSection);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
const animateNumbers = (entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const numbers = entry.target.querySelectorAll('.stat-number');
|
||||
|
||||
numbers.forEach(number => {
|
||||
const target = parseInt(number.getAttribute('data-target'));
|
||||
const duration = 2000; // 2초
|
||||
const increment = target / (duration / 16);
|
||||
let current = 0;
|
||||
|
||||
const updateNumber = () => {
|
||||
current += increment;
|
||||
if (current < target) {
|
||||
number.textContent = Math.floor(current).toLocaleString('ko-KR');
|
||||
requestAnimationFrame(updateNumber);
|
||||
} else {
|
||||
number.textContent = target.toLocaleString('ko-KR');
|
||||
}
|
||||
};
|
||||
|
||||
updateNumber();
|
||||
});
|
||||
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(animateNumbers, observerOptions);
|
||||
const statsSection = document.querySelector('.api-stats-section');
|
||||
|
||||
if (statsSection) {
|
||||
observer.observe(statsSection);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main app-management-layout">
|
||||
<div class="service-main container" style="padding-top: 70px;">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
@@ -24,11 +24,12 @@
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef"
|
||||
stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
|
||||
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
|
||||
|
||||
@@ -1,80 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>비밀번호 변경</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main app-management-layout">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>비밀번호 변경</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main app-management-layout">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
||||
|
||||
<form th:action="@{/password/verify}" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<form th:action="@{/password/verify}" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
<p>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">현재 비밀번호</span>
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</p>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" name="currentPassword" class="form-input"
|
||||
placeholder="비밀번호 입력" required>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">현재 비밀번호</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" name="currentPassword" class="form-input"
|
||||
placeholder="비밀번호 입력" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="justify-content: flex-end;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="submit" class="btn-apply btn-primary">확인</button>
|
||||
<div class="form-actions" style="justify-content: flex-end;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}"
|
||||
th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="submit" class="btn-apply btn-primary">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
customPopups.showAlert([[${error}]]);
|
||||
})
|
||||
</script>
|
||||
<script th:if="${forcedPasswordReset}" th:inline="javascript">
|
||||
// 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능
|
||||
$(function () {
|
||||
$('#customConfirmCloseButton').hide();
|
||||
$('#customConfirmYesButton').text('변경');
|
||||
$('#customConfirmNoButton').text('로그아웃');
|
||||
customPopups.showConfirm(
|
||||
'계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.',
|
||||
function (selection) {
|
||||
if (selection) {
|
||||
// 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행
|
||||
$('#customConfirmCloseButton').show();
|
||||
} else {
|
||||
// 로그아웃
|
||||
window.location.href = [[@{/actionLogout.do}]];
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${ error }]]);
|
||||
})
|
||||
</script>
|
||||
<script th:if="${forcedPasswordReset}" th:inline="javascript">
|
||||
// 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능
|
||||
$(function () {
|
||||
$('#customConfirmCloseButton').hide();
|
||||
$('#customConfirmYesButton').text('변경');
|
||||
$('#customConfirmNoButton').text('로그아웃');
|
||||
customPopups.showConfirm(
|
||||
'계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.',
|
||||
function (selection) {
|
||||
if (selection) {
|
||||
// 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행
|
||||
$('#customConfirmCloseButton').show();
|
||||
} else {
|
||||
// 로그아웃
|
||||
window.location.href = [[@{/ actionLogout.do}]];
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
</section>
|
||||
</script>
|
||||
</section>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -9,34 +9,49 @@
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="org-register-container">
|
||||
<div class="corp-manager-wrapper">
|
||||
<div class="common-title-bar">
|
||||
<h2 class="common-title">기본 정보</h2>
|
||||
</div>
|
||||
<div class="register-form-container">
|
||||
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<div class="corp-manager-wrapper">
|
||||
<h2 class="page-outer-title">정보 관리</h2>
|
||||
|
||||
<form id="updateForm" method="post" th:action="@{/mypage/update}">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<div class="register-form">
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">소속기관</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text" th:value="${user.portalOrg.orgName}" name="organization"
|
||||
class="form-input input-readonly info-value-box" placeholder="소속기관" disabled="disabled">
|
||||
</div>
|
||||
|
||||
<div class="register-form-container">
|
||||
<!-- Section Title -->
|
||||
<div class="inner-section-title">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
<h3>기본 정보</h3>
|
||||
</div>
|
||||
|
||||
<div class="register-form">
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">소속기관</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text" th:value="${user.portalOrg.orgName}" name="organization"
|
||||
class="form-input input-readonly" placeholder="소속기관" disabled="disabled">
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
|
||||
</div>
|
||||
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions form-actions--with-withdrawal">
|
||||
<a class="withdrawal-link"><img th:src="@{/img/btn_withdrawal.png}" alt="회원탈퇴">회원탈퇴</a>
|
||||
<div class="form-actions-buttons">
|
||||
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="button" class="btn btn-submit btn-primary submit-btn">수정 적용</button>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<a class="withdrawal-link btn-withdrawal">회원탈퇴</a>
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="button" class="btn-apply btn-primary submit-btn">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,524 +1,529 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" alt="법인회원가입" class="title-image">
|
||||
<h1>법인회원가입</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" alt="법인회원가입" class="title-image">
|
||||
<h1>법인회원가입</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section layout:fragment="contentFragment">
|
||||
<section layout:fragment="contentFragment">
|
||||
|
||||
|
||||
<div class="org-register-page corporate-register">
|
||||
<!-- Page Title Banner -->
|
||||
<div class="org-register-page corporate-register">
|
||||
<!-- Page Title Banner -->
|
||||
|
||||
<div class="org-register-container">
|
||||
<!-- Hidden Fields -->
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<input type="hidden" name="registrationType" th:value="${registrationType}"/>
|
||||
<div class="org-register-container">
|
||||
<!-- Hidden Fields -->
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<input type="hidden" name="registrationType" th:value="${registrationType}" />
|
||||
|
||||
<!-- Alert Container -->
|
||||
<div id="alertContainer"></div>
|
||||
|
||||
<!-- Registration Card Wrapper -->
|
||||
<div class="register-card-wrapper">
|
||||
<!-- Info Notice -->
|
||||
<div class="org-info-notice">
|
||||
<div class="notice-icon-wrapper">
|
||||
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="notice-text">법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- Agreement Section -->
|
||||
<th:block th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}"></th:block>
|
||||
|
||||
<form name="portalOrg" id="registerForm" role="form" th:action="@{/signup/portalOrg}"
|
||||
th:object="${portalOrg}" method="post">
|
||||
|
||||
<!-- Corporate Basic Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>법인 기본 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
<th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block>
|
||||
|
||||
<!-- Corporate Admin Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
|
||||
<h3>법인 관리자 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
<!-- Alert Container -->
|
||||
<div id="alertContainer"></div>
|
||||
|
||||
<!-- Registration Card Wrapper -->
|
||||
<div class="register-card-wrapper">
|
||||
<!-- Info Notice -->
|
||||
<div class="org-info-notice">
|
||||
<div class="notice-icon-wrapper">
|
||||
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="notice-text">법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</p>
|
||||
<p class="notice-text">법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<th:block th:if="${registrationType == 'corporate'}">
|
||||
<th:block th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoForm}"></th:block>
|
||||
<!-- Agreement Section -->
|
||||
<th:block
|
||||
th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}">
|
||||
</th:block>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="org-action-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
|
||||
<button type="button" class="btn btn-primary btn_register">가입신청</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form name="portalOrg" id="registerForm" role="form" th:action="@{/signup/portalOrg}" th:object="${portalOrg}"
|
||||
method="post">
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div class="org-loading-overlay" id="loadingOverlay">
|
||||
<div class="spinner"></div>
|
||||
<!-- Corporate Basic Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>법인 기본 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
<th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block>
|
||||
|
||||
<!-- Corporate Admin Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
|
||||
<h3>법인 관리자 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
|
||||
<div class="org-info-notice">
|
||||
<div class="notice-icon-wrapper">
|
||||
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="notice-text">법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</p>
|
||||
</div>
|
||||
|
||||
<th:block th:if="${registrationType == 'corporate'}">
|
||||
<th:block th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoForm}"></th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="org-action-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
|
||||
<button type="button" class="btn btn-primary btn_register">가입신청</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div class="org-loading-overlay" id="loadingOverlay">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${error}]]);
|
||||
});
|
||||
</script>
|
||||
<script th:replace="~{apps/register/userAgreementContent :: agreementScript}"></script>
|
||||
<script th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoScript}"></script>
|
||||
<script th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoScript}"></script>
|
||||
<script th:replace="~{apps/register/components/emailChangeForm :: emailChangeScript}"></script>
|
||||
<script th:replace="~{apps/register/components/newUserInfoForm :: newUserScript}"></script>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
let form = document.getElementById('registerForm');
|
||||
let agreementForm = document.getElementById('agreementForm');
|
||||
let btn_register = document.querySelector('.btn_register');
|
||||
let isAuthVerified = false;
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${ error }]]);
|
||||
});
|
||||
</script>
|
||||
<script th:replace="~{apps/register/userAgreementContent :: agreementScript}"></script>
|
||||
<script th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoScript}"></script>
|
||||
<script th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoScript}"></script>
|
||||
<script th:replace="~{apps/register/components/emailChangeForm :: emailChangeScript}"></script>
|
||||
<script th:replace="~{apps/register/components/newUserInfoForm :: newUserScript}"></script>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
let form = document.getElementById('registerForm');
|
||||
let agreementForm = document.getElementById('agreementForm');
|
||||
let btn_register = document.querySelector('.btn_register');
|
||||
let isAuthVerified = false;
|
||||
|
||||
// registrationScenario를 hidden input에서 가져오도록 수정
|
||||
let registrationScenario = document.getElementById('registrationScenario')?.value || 'new';
|
||||
// registrationScenario를 hidden input에서 가져오도록 수정
|
||||
let registrationScenario = document.getElementById('registrationScenario')?.value || 'new';
|
||||
|
||||
// hidden input의 값이 변경되면 registrationScenario 변수도 업데이트
|
||||
const scenarioInput = document.getElementById('registrationScenario');
|
||||
if (scenarioInput) {
|
||||
scenarioInput.addEventListener('change', function () {
|
||||
registrationScenario = this.value;
|
||||
console.log('Registration Scenario Updated:', registrationScenario);
|
||||
});
|
||||
|
||||
// MutationObserver를 사용하여 값 변경 감지
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
|
||||
registrationScenario = scenarioInput.value;
|
||||
console.log('Registration Scenario Updated (from mutation):', registrationScenario);
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(scenarioInput, {attributes: true});
|
||||
}
|
||||
|
||||
// 시나리오별 필수 필드 정의
|
||||
const requiredFieldsByScenario = {
|
||||
new: {
|
||||
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
|
||||
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
|
||||
},
|
||||
retain: {
|
||||
user: ['loginId', 'passwordConfirmIndividual'],
|
||||
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
|
||||
},
|
||||
change: {
|
||||
user: ['loginId', 'passwordConfirmEmailChange', 'newLoginId'],
|
||||
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
|
||||
}
|
||||
};
|
||||
|
||||
// 제출 대상 org 필드 (검증 필수목록과 별개 - 선택입력 포함 전 필드 전송)
|
||||
// 제외: ipWhitelist(가입 미입력), orgCode(관리자/시스템 부여)
|
||||
const submitOrgFields = [
|
||||
'compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files',
|
||||
'ceoName', 'orgAddr', 'serviceName', 'orgPhoneNumber', 'scPhoneNumber',
|
||||
'orgSectors', 'orgIndustryType'
|
||||
];
|
||||
|
||||
// 이메일 유효성 검사 함수
|
||||
function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
function collectFormData() {
|
||||
const form = document.createElement('form');
|
||||
form.enctype = 'multipart/form-data';
|
||||
form.method = 'POST';
|
||||
form.action = '/signup/portalOrg';
|
||||
form.style.display = 'none';
|
||||
|
||||
const fields = requiredFieldsByScenario[registrationScenario];
|
||||
|
||||
if (!fields) {
|
||||
console.error('Unknown scenario');
|
||||
return form;
|
||||
}
|
||||
|
||||
// 시나리오 값 추가
|
||||
// hidden input의 값이 변경되면 registrationScenario 변수도 업데이트
|
||||
const scenarioInput = document.getElementById('registrationScenario');
|
||||
if (scenarioInput) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'registrationScenario';
|
||||
input.value = scenarioInput.value;
|
||||
form.appendChild(input);
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'registrationScenario';
|
||||
input.value = registrationScenario;
|
||||
form.appendChild(input);
|
||||
scenarioInput.addEventListener('change', function () {
|
||||
registrationScenario = this.value;
|
||||
console.log('Registration Scenario Updated:', registrationScenario);
|
||||
});
|
||||
|
||||
// MutationObserver를 사용하여 값 변경 감지
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
|
||||
registrationScenario = scenarioInput.value;
|
||||
console.log('Registration Scenario Updated (from mutation):', registrationScenario);
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(scenarioInput, { attributes: true });
|
||||
}
|
||||
|
||||
// user와 org 필드를 따로 처리
|
||||
fields.user.forEach(fieldId => {
|
||||
const element = document.getElementById(fieldId);
|
||||
if (element) {
|
||||
// 시나리오별 필수 필드 정의
|
||||
const requiredFieldsByScenario = {
|
||||
new: {
|
||||
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
|
||||
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
|
||||
},
|
||||
retain: {
|
||||
user: ['loginId', 'passwordConfirmIndividual'],
|
||||
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
|
||||
},
|
||||
change: {
|
||||
user: ['loginId', 'passwordConfirmEmailChange', 'newLoginId'],
|
||||
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
|
||||
}
|
||||
};
|
||||
|
||||
// 제출 대상 org 필드 (검증 필수목록과 별개 - 선택입력 포함 전 필드 전송)
|
||||
// 제외: ipWhitelist(가입 미입력), orgCode(관리자/시스템 부여)
|
||||
const submitOrgFields = [
|
||||
'compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files',
|
||||
'ceoName', 'orgAddr', 'serviceName', 'orgPhoneNumber', 'scPhoneNumber',
|
||||
'orgSectors', 'orgIndustryType'
|
||||
];
|
||||
|
||||
// 이메일 유효성 검사 함수
|
||||
function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
function collectFormData() {
|
||||
const form = document.createElement('form');
|
||||
form.enctype = 'multipart/form-data';
|
||||
form.method = 'POST';
|
||||
form.action = '/signup/portalOrg';
|
||||
form.style.display = 'none';
|
||||
|
||||
const fields = requiredFieldsByScenario[registrationScenario];
|
||||
|
||||
if (!fields) {
|
||||
console.error('Unknown scenario');
|
||||
return form;
|
||||
}
|
||||
|
||||
// 시나리오 값 추가
|
||||
const scenarioInput = document.getElementById('registrationScenario');
|
||||
if (scenarioInput) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = fieldId;
|
||||
input.value = element.value;
|
||||
input.name = 'registrationScenario';
|
||||
input.value = scenarioInput.value;
|
||||
form.appendChild(input);
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'registrationScenario';
|
||||
input.value = registrationScenario;
|
||||
form.appendChild(input);
|
||||
}
|
||||
});
|
||||
|
||||
submitOrgFields.forEach(fieldId => {
|
||||
const element = document.getElementById(fieldId);
|
||||
if (element) {
|
||||
if (element.type === 'file') {
|
||||
const files = element.files;
|
||||
if (files.length > 0) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.name = fieldId;
|
||||
input.files = files;
|
||||
form.appendChild(input);
|
||||
}
|
||||
} else {
|
||||
// user와 org 필드를 따로 처리
|
||||
fields.user.forEach(fieldId => {
|
||||
const element = document.getElementById(fieldId);
|
||||
if (element) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = fieldId;
|
||||
input.value = element.value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add confirmPassword manually based on the scenario
|
||||
if (registrationScenario === 'retain') {
|
||||
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
|
||||
if (passwordConfirmIndividual) {
|
||||
submitOrgFields.forEach(fieldId => {
|
||||
const element = document.getElementById(fieldId);
|
||||
if (element) {
|
||||
if (element.type === 'file') {
|
||||
const files = element.files;
|
||||
if (files.length > 0) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.name = fieldId;
|
||||
input.files = files;
|
||||
form.appendChild(input);
|
||||
}
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = fieldId;
|
||||
input.value = element.value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add confirmPassword manually based on the scenario
|
||||
if (registrationScenario === 'retain') {
|
||||
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
|
||||
if (passwordConfirmIndividual) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'confirmPassword';
|
||||
input.value = passwordConfirmIndividual.value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
} else if (registrationScenario === 'change') {
|
||||
const passwordConfirmEmailChange = document.getElementById('passwordConfirmEmailChange');
|
||||
if (passwordConfirmEmailChange) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'confirmPassword';
|
||||
input.value = passwordConfirmEmailChange.value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 필드 추가
|
||||
if (document.getElementById('termsOfUse')) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'confirmPassword';
|
||||
input.value = passwordConfirmIndividual.value;
|
||||
input.name = 'termsOfUse';
|
||||
input.value = document.getElementById('termsOfUse').checked;
|
||||
form.appendChild(input);
|
||||
}
|
||||
} else if (registrationScenario === 'change') {
|
||||
const passwordConfirmEmailChange = document.getElementById('passwordConfirmEmailChange');
|
||||
if (passwordConfirmEmailChange) {
|
||||
|
||||
if (document.getElementById('privacyCollect')) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'confirmPassword';
|
||||
input.value = passwordConfirmEmailChange.value;
|
||||
input.name = 'privacyCollect';
|
||||
input.value = document.getElementById('privacyCollect').checked;
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
if (document.getElementById('notificationConsent')) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'notificationConsent';
|
||||
input.value = document.getElementById('notificationConsent').checked;
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
// 시스템 필드 추가
|
||||
const csrfToken = document.querySelector('input[name="_csrf"]');
|
||||
if (csrfToken) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = '_csrf';
|
||||
input.value = csrfToken.value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
const registrationType = document.querySelector('input[name="registrationType"]');
|
||||
if (registrationType) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'registrationType';
|
||||
input.value = registrationType.value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
console.log('Collected Form Data for scenario:',
|
||||
document.getElementById('registrationScenario')?.value || registrationScenario);
|
||||
return form;
|
||||
}
|
||||
|
||||
// 시나리오별 유효성 검사
|
||||
function validateFormByScenario() {
|
||||
|
||||
const currentScenario = document.getElementById('registrationScenario')?.value || 'new';
|
||||
console.log('Registration Scenario:', currentScenario);
|
||||
|
||||
if (!currentScenario) {
|
||||
console.log('Unknown scenario');
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (registrationScenario) {
|
||||
case 'new':
|
||||
const loginIdElement = document.getElementById('loginId');
|
||||
const userNameElement = document.getElementById('userName');
|
||||
|
||||
const isEmailValid = loginIdElement ? isValidEmail(loginIdElement.value) : false;
|
||||
const hasUserName = userNameElement ? userNameElement.value.trim() !== '' : false;
|
||||
const isPasswordValid = document.getElementById('isPasswordValid').value.trim() === 'true';
|
||||
const isPasswordMatch = document.getElementById('isPasswordMatch').value.trim() === 'true';
|
||||
|
||||
console.log('New User Validation:', {
|
||||
email: loginIdElement?.value,
|
||||
isEmailValid,
|
||||
isPasswordValid,
|
||||
isPasswordMatch,
|
||||
userName: userNameElement?.value,
|
||||
hasUserName,
|
||||
isAuthVerified,
|
||||
requiredFields: currentScenario
|
||||
});
|
||||
return isEmailValid && isPasswordValid && isPasswordMatch && hasUserName && isAuthVerified;
|
||||
|
||||
case 'retain':
|
||||
const passwordConfirmElement = document.getElementById('passwordConfirmIndividual');
|
||||
const hasPassword = passwordConfirmElement ? passwordConfirmElement.value.length >= 8 : false;
|
||||
const retainLoginIdElement = document.getElementById('loginId');
|
||||
|
||||
console.log('Retain User Validation:', {
|
||||
email: retainLoginIdElement?.value,
|
||||
isEmailValid: retainLoginIdElement ? isValidEmail(retainLoginIdElement.value) : false,
|
||||
passwordLength: passwordConfirmElement?.value.length,
|
||||
hasPassword,
|
||||
requiredFields: currentScenario
|
||||
});
|
||||
return retainLoginIdElement && isValidEmail(retainLoginIdElement.value) && hasPassword;
|
||||
|
||||
case 'change':
|
||||
const changeLoginIdElement = document.getElementById('loginId');
|
||||
const newLoginIdElement = document.getElementById('newLoginId');
|
||||
const changePasswordElement = document.getElementById('passwordConfirmEmailChange');
|
||||
const hasChangePassword = changePasswordElement ? changePasswordElement.value.length >= 8 : false;
|
||||
|
||||
console.log('Change Email Validation:', {
|
||||
currentEmail: changeLoginIdElement?.value,
|
||||
newEmail: newLoginIdElement?.value,
|
||||
hasPassword: hasChangePassword,
|
||||
requiredFields: currentScenario
|
||||
});
|
||||
return changeLoginIdElement && newLoginIdElement &&
|
||||
isValidEmail(changeLoginIdElement.value) &&
|
||||
hasChangePassword &&
|
||||
isValidEmail(newLoginIdElement.value);
|
||||
|
||||
default:
|
||||
console.log('Unknown scenario');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 필드 추가
|
||||
if (document.getElementById('termsOfUse')) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'termsOfUse';
|
||||
input.value = document.getElementById('termsOfUse').checked;
|
||||
form.appendChild(input);
|
||||
// 공통 필드 유효성 검사
|
||||
function validateCommonFields() {
|
||||
|
||||
if (!validatePhoneNumber('org') || !validatePhoneNumber('sc')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const notificationConsentEl = document.getElementById('notificationConsent');
|
||||
const validations = {
|
||||
termsOfUse: $('#termsOfUse').prop('checked'),
|
||||
privacyPolicy: $('#privacyCollect').prop('checked'),
|
||||
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
|
||||
compRegNo: $('#compRegNo').val().trim() !== '',
|
||||
corpRegNo: $('#corpRegNo').val().trim() !== '',
|
||||
orgName: $('#orgName').val().trim() !== '',
|
||||
compRegFile: $('#compRegFile').val().trim() !== ''
|
||||
};
|
||||
|
||||
console.log('Common Fields Validation:', validations);
|
||||
return Object.values(validations).every(value => value === true);
|
||||
}
|
||||
|
||||
if (document.getElementById('privacyCollect')) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'privacyCollect';
|
||||
input.value = document.getElementById('privacyCollect').checked;
|
||||
form.appendChild(input);
|
||||
// 전체 폼 유효성 검사
|
||||
function validateForm() {
|
||||
const scenarioValidation = validateFormByScenario();
|
||||
const commonValidation = validateCommonFields();
|
||||
|
||||
console.log('Form validation result:', {
|
||||
scenarioValidation,
|
||||
commonValidation,
|
||||
registrationScenario
|
||||
});
|
||||
|
||||
return scenarioValidation && commonValidation;
|
||||
}
|
||||
|
||||
if (document.getElementById('notificationConsent')) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'notificationConsent';
|
||||
input.value = document.getElementById('notificationConsent').checked;
|
||||
form.appendChild(input);
|
||||
}
|
||||
// 미입력/미충족 항목을 구체적으로 수집
|
||||
function collectFormErrors() {
|
||||
const errors = [];
|
||||
|
||||
// 시스템 필드 추가
|
||||
const csrfToken = document.querySelector('input[name="_csrf"]');
|
||||
if (csrfToken) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = '_csrf';
|
||||
input.value = csrfToken.value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
const registrationType = document.querySelector('input[name="registrationType"]');
|
||||
if (registrationType) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'registrationType';
|
||||
input.value = registrationType.value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
console.log('Collected Form Data for scenario:',
|
||||
document.getElementById('registrationScenario')?.value || registrationScenario);
|
||||
return form;
|
||||
}
|
||||
|
||||
// 시나리오별 유효성 검사
|
||||
function validateFormByScenario() {
|
||||
|
||||
const currentScenario = document.getElementById('registrationScenario')?.value || 'new';
|
||||
console.log('Registration Scenario:', currentScenario);
|
||||
|
||||
if (!currentScenario) {
|
||||
console.log('Unknown scenario');
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (registrationScenario) {
|
||||
case 'new':
|
||||
// 시나리오별 항목
|
||||
if (registrationScenario === 'new') {
|
||||
const loginIdElement = document.getElementById('loginId');
|
||||
const userNameElement = document.getElementById('userName');
|
||||
|
||||
const isEmailValid = loginIdElement ? isValidEmail(loginIdElement.value) : false;
|
||||
const hasUserName = userNameElement ? userNameElement.value.trim() !== '' : false;
|
||||
const isPasswordValid = document.getElementById('isPasswordValid').value.trim() === 'true';
|
||||
const isPasswordMatch = document.getElementById('isPasswordMatch').value.trim() === 'true';
|
||||
|
||||
console.log('New User Validation:', {
|
||||
email: loginIdElement?.value,
|
||||
isEmailValid,
|
||||
isPasswordValid,
|
||||
isPasswordMatch,
|
||||
userName: userNameElement?.value,
|
||||
hasUserName,
|
||||
isAuthVerified,
|
||||
requiredFields: currentScenario
|
||||
});
|
||||
return isEmailValid && isPasswordValid && isPasswordMatch && hasUserName && isAuthVerified;
|
||||
|
||||
case 'retain':
|
||||
const passwordConfirmElement = document.getElementById('passwordConfirmIndividual');
|
||||
const hasPassword = passwordConfirmElement ? passwordConfirmElement.value.length >= 8 : false;
|
||||
const retainLoginIdElement = document.getElementById('loginId');
|
||||
|
||||
console.log('Retain User Validation:', {
|
||||
email: retainLoginIdElement?.value,
|
||||
isEmailValid: retainLoginIdElement ? isValidEmail(retainLoginIdElement.value) : false,
|
||||
passwordLength: passwordConfirmElement?.value.length,
|
||||
hasPassword,
|
||||
requiredFields: currentScenario
|
||||
});
|
||||
return retainLoginIdElement && isValidEmail(retainLoginIdElement.value) && hasPassword;
|
||||
|
||||
case 'change':
|
||||
const changeLoginIdElement = document.getElementById('loginId');
|
||||
const newLoginIdElement = document.getElementById('newLoginId');
|
||||
const changePasswordElement = document.getElementById('passwordConfirmEmailChange');
|
||||
const hasChangePassword = changePasswordElement ? changePasswordElement.value.length >= 8 : false;
|
||||
|
||||
console.log('Change Email Validation:', {
|
||||
currentEmail: changeLoginIdElement?.value,
|
||||
newEmail: newLoginIdElement?.value,
|
||||
hasPassword: hasChangePassword,
|
||||
requiredFields: currentScenario
|
||||
});
|
||||
return changeLoginIdElement && newLoginIdElement &&
|
||||
isValidEmail(changeLoginIdElement.value) &&
|
||||
hasChangePassword &&
|
||||
isValidEmail(newLoginIdElement.value);
|
||||
|
||||
default:
|
||||
console.log('Unknown scenario');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 필드 유효성 검사
|
||||
function validateCommonFields() {
|
||||
|
||||
if(!validatePhoneNumber('org') || !validatePhoneNumber('sc')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const notificationConsentEl = document.getElementById('notificationConsent');
|
||||
const validations = {
|
||||
termsOfUse: $('#termsOfUse').prop('checked'),
|
||||
privacyPolicy: $('#privacyCollect').prop('checked'),
|
||||
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
|
||||
compRegNo: $('#compRegNo').val().trim() !== '',
|
||||
corpRegNo: $('#corpRegNo').val().trim() !== '',
|
||||
orgName: $('#orgName').val().trim() !== '',
|
||||
compRegFile: $('#compRegFile').val().trim() !== ''
|
||||
};
|
||||
|
||||
console.log('Common Fields Validation:', validations);
|
||||
return Object.values(validations).every(value => value === true);
|
||||
}
|
||||
|
||||
// 전체 폼 유효성 검사
|
||||
function validateForm() {
|
||||
const scenarioValidation = validateFormByScenario();
|
||||
const commonValidation = validateCommonFields();
|
||||
|
||||
console.log('Form validation result:', {
|
||||
scenarioValidation,
|
||||
commonValidation,
|
||||
registrationScenario
|
||||
});
|
||||
|
||||
return scenarioValidation && commonValidation;
|
||||
}
|
||||
|
||||
// 미입력/미충족 항목을 구체적으로 수집
|
||||
function collectFormErrors() {
|
||||
const errors = [];
|
||||
|
||||
// 시나리오별 항목
|
||||
if (registrationScenario === 'new') {
|
||||
const loginIdElement = document.getElementById('loginId');
|
||||
const userNameElement = document.getElementById('userName');
|
||||
|
||||
if (!(loginIdElement && isValidEmail(loginIdElement.value))) errors.push('이메일 아이디를 올바르게 입력해주세요.');
|
||||
if (!(userNameElement && userNameElement.value.trim() !== '')) errors.push('성명을 입력해주세요.');
|
||||
if (!isAuthVerified) errors.push('휴대폰 인증을 완료해주세요.');
|
||||
if (document.getElementById('isPasswordValid')?.value.trim() !== 'true') errors.push('비밀번호 조건을 확인해주세요.');
|
||||
if (document.getElementById('isPasswordMatch')?.value.trim() !== 'true') errors.push('비밀번호 확인이 일치하지 않습니다.');
|
||||
}
|
||||
|
||||
// 공통 항목
|
||||
const notificationConsentEl = document.getElementById('notificationConsent');
|
||||
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
|
||||
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
|
||||
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
|
||||
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
|
||||
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
|
||||
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
|
||||
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
let isSubmitting = false;
|
||||
|
||||
// 등록 버튼 클릭 이벤트
|
||||
btn_register.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Prevent resubmission
|
||||
if (isSubmitting) {
|
||||
console.log('Form is already being submitted...');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
btn_register.disabled = true;
|
||||
|
||||
// Show loading overlay
|
||||
document.getElementById('loadingOverlay').classList.add('active');
|
||||
|
||||
try {
|
||||
form.classList.add('was-validated');
|
||||
if (agreementForm) {
|
||||
agreementForm.classList.add('was-validated');
|
||||
if (!(loginIdElement && isValidEmail(loginIdElement.value))) errors.push('이메일 아이디를 올바르게 입력해주세요.');
|
||||
if (!(userNameElement && userNameElement.value.trim() !== '')) errors.push('성명을 입력해주세요.');
|
||||
if (!isAuthVerified) errors.push('휴대폰 인증을 완료해주세요.');
|
||||
if (document.getElementById('isPasswordValid')?.value.trim() !== 'true') errors.push('비밀번호 조건을 확인해주세요.');
|
||||
if (document.getElementById('isPasswordMatch')?.value.trim() !== 'true') errors.push('비밀번호 확인이 일치하지 않습니다.');
|
||||
}
|
||||
|
||||
console.log('Validating form...');
|
||||
// 공통 항목
|
||||
const notificationConsentEl = document.getElementById('notificationConsent');
|
||||
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
|
||||
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
|
||||
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
|
||||
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
|
||||
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
|
||||
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
|
||||
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
|
||||
|
||||
if (validateForm()) {
|
||||
console.log('Form validation passed, collecting form data...');
|
||||
const form = collectFormData();
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
} else {
|
||||
console.log('Form validation failed');
|
||||
return errors;
|
||||
}
|
||||
|
||||
// 현재 상태 출력
|
||||
const scenarioValidation = validateFormByScenario();
|
||||
const commonValidation = validateCommonFields();
|
||||
let isSubmitting = false;
|
||||
|
||||
const isPasswordValid = document.getElementById('isPasswordValid')?.value.trim() === 'true';
|
||||
const isPasswordMatch = document.getElementById('isPasswordMatch')?.value.trim() === 'true';
|
||||
// 등록 버튼 클릭 이벤트
|
||||
btn_register.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
console.log('Validation details:', {
|
||||
scenarioValidation,
|
||||
commonValidation,
|
||||
registrationScenario,
|
||||
isAuthVerified,
|
||||
isPasswordValid,
|
||||
isPasswordMatch
|
||||
});
|
||||
// Prevent resubmission
|
||||
if (isSubmitting) {
|
||||
console.log('Form is already being submitted...');
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = collectFormErrors();
|
||||
let errorMessage = errors.length > 0
|
||||
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
|
||||
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
|
||||
customPopups.showAlert(errorMessage);
|
||||
isSubmitting = true;
|
||||
btn_register.disabled = true;
|
||||
|
||||
// Reset submission state since validation failed
|
||||
// Show loading overlay
|
||||
document.getElementById('loadingOverlay').classList.add('active');
|
||||
|
||||
try {
|
||||
form.classList.add('was-validated');
|
||||
if (agreementForm) {
|
||||
agreementForm.classList.add('was-validated');
|
||||
}
|
||||
|
||||
console.log('Validating form...');
|
||||
|
||||
if (validateForm()) {
|
||||
console.log('Form validation passed, collecting form data...');
|
||||
const form = collectFormData();
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
} else {
|
||||
console.log('Form validation failed');
|
||||
|
||||
// 현재 상태 출력
|
||||
const scenarioValidation = validateFormByScenario();
|
||||
const commonValidation = validateCommonFields();
|
||||
|
||||
const isPasswordValid = document.getElementById('isPasswordValid')?.value.trim() === 'true';
|
||||
const isPasswordMatch = document.getElementById('isPasswordMatch')?.value.trim() === 'true';
|
||||
|
||||
console.log('Validation details:', {
|
||||
scenarioValidation,
|
||||
commonValidation,
|
||||
registrationScenario,
|
||||
isAuthVerified,
|
||||
isPasswordValid,
|
||||
isPasswordMatch
|
||||
});
|
||||
|
||||
const errors = collectFormErrors();
|
||||
let errorMessage = errors.length > 0
|
||||
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
|
||||
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
|
||||
customPopups.showAlert(errorMessage);
|
||||
|
||||
// Reset submission state since validation failed
|
||||
isSubmitting = false;
|
||||
btn_register.disabled = false;
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
// Reset submission state on error
|
||||
isSubmitting = false;
|
||||
btn_register.disabled = false;
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
// Reset submission state on error
|
||||
isSubmitting = false;
|
||||
btn_register.disabled = false;
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
});
|
||||
|
||||
// 신규 이메일 중복 체크 응답 처리 함수
|
||||
function handleNewEmailValidationResponse(response) {
|
||||
if (response.valid) {
|
||||
customPopups.showAlert(response.message || '신규 이메일 정보입니다.');
|
||||
} else {
|
||||
customPopups.showAlert(response.message || '이미 사용 중인 이메일입니다.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 신규 이메일 중복 체크 응답 처리 함수
|
||||
function handleNewEmailValidationResponse(response) {
|
||||
if (response.valid) {
|
||||
customPopups.showAlert(response.message || '신규 이메일 정보입니다.');
|
||||
} else {
|
||||
customPopups.showAlert(response.message || '이미 사용 중인 이메일입니다.');
|
||||
}
|
||||
}
|
||||
// 인증 완료 이벤트 리스너
|
||||
document.addEventListener('authVerified', function () {
|
||||
isAuthVerified = true;
|
||||
});
|
||||
|
||||
// 인증 완료 이벤트 리스너
|
||||
document.addEventListener('authVerified', function () {
|
||||
isAuthVerified = true;
|
||||
// 취소 버튼 클릭 시
|
||||
document.getElementById('cancelButton').addEventListener('click', function () {
|
||||
location.href = '/login';
|
||||
});
|
||||
});
|
||||
|
||||
// 취소 버튼 클릭 시
|
||||
document.getElementById('cancelButton').addEventListener('click', function () {
|
||||
location.href = '/login';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -24,25 +24,24 @@
|
||||
<h3>초대 정보</h3>
|
||||
</div>
|
||||
|
||||
<div class="account-recovery-card" style="margin-bottom: 32px;">
|
||||
<div class="account-recovery-result">
|
||||
<div class="result-header">
|
||||
<i class="fas fa-envelope-open-text result-icon" style="color: #1a73e8;"></i>
|
||||
<h2 class="result-title" style="font-size: 18px;">
|
||||
<strong th:text="${userName}"></strong>님, 법인회원 초대가 도착했습니다.
|
||||
</h2>
|
||||
<div class="invitation_alert_banner">
|
||||
<div class="banner-content-wrapper">
|
||||
<div class="banner-icon-area">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="#E8F1FF" stroke="#1976D2" stroke-width="2"/>
|
||||
<path d="M12 8V13" stroke="#1976D2" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="16" r="1.25" fill="#1976D2"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="result-info-box">
|
||||
<p class="info-text">
|
||||
<strong th:text="${orgName}"></strong>에서
|
||||
DJBank API Portal 법인회원으로 초대하였습니다.
|
||||
</p>
|
||||
<p class="info-text" style="margin-top: 8px;">
|
||||
<div class="banner-text-area">
|
||||
<strong>
|
||||
<span th:text="${userName}"></span>님, 법인회원 초대가 도착했습니다.
|
||||
</strong>
|
||||
<p>
|
||||
<strong><span th:text="${orgName}"></span></strong>에서 DJBank API Portal 법인회원으로 초대하였습니다.<br>
|
||||
초대를 수락하시면 법인회원으로 전환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,50 +1,62 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>회원가입</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="account-recovery-page">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
<!-- Result Content Area -->
|
||||
<div class="account-recovery-result">
|
||||
<div class="result-header">
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<rect width="64" height="64" fill="url(#pattern0_1130_6799)"/>
|
||||
<defs>
|
||||
<pattern id="pattern0_1130_6799" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_1130_6799" transform="translate(0 0.104294) scale(0.00613497)"/>
|
||||
</pattern>
|
||||
<image id="image0_1130_6799" width="163" height="129" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKMAAACBCAYAAACsCAq9AAAACXBIWXMAABcSAAAXEgFnn9JSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACtpJREFUeNrsnUFoY1UUhm8GXYgwFmcxG2FSBZEBp6m60IU0AREGhCZrF003umzrxpW20Y1uTAtudJN04bodGBBEaLrShdoMwiAI9g24mYUQBXXhxnuSk87rTJPc8969L/e+9x94lOkkzXt53/vPOfeee65SMJgnVsJXcN6++emXsv5BR0UfCxNe1tfH4M2XXujhGwOMtuGr62NFH9UpAKopYNJxSx89DegAWAFGCYAEXFMfa6yANq2rj32oJmA0gbDNSrjg+OMifbQ0lF1gBhgfhnBTHxsZQHiRG9+CUgJGApHiwA4nJfO0XVZKxJRFhFGDuKN/bHt0SuS6GxrIPtArCIzslg84O/bR1hFLPmqXcgrikccgknVYtWF5VcYYiJVATrmrFXIdGOZMGQMEkaypz3sTGObPTdsCMeLMlxSrRodWL/Igi+N/U1asj0N92MiM2xrIJlDMiZvWN5MGstMoDEFFCcWeBi8SfjYNoNNMTj3l59eKnmWXcgBinTPnpBDskRKmHf/jOe5OisSpr89hGTCGHSeeqmSzKuRm120PQvPD0Ul4TjQovgMYi+WeaXpu19NkalEaKgDG+YNYZlWUWmYDzvocSSGlyQmVodWQTYdl2z6DSMZjiNLPq/J8OmAMSBWbPoMYDwnUqHJHYhuAMRyT3qzDec0Fc4LUULIxyTo/cIAxAJOoIkEw1yk3TkhawrfVAaP/Llpapb3lQw0hZ+8Sd70GGP23FcFrI89KtSTqWCmaqw4RRkmmuefTiesHgwbaI0fXChgzdtHkniUDyV0PL0PygKwARn9NAqKva5h7jq4XMHoM47GPF8CVORFgDB9GSRbd8/g6jONGDk0Ao4e25OKGz8Ekql0BjIErY1ErXwAjDAYYYYARBgOMoiy0jNsLGF3aPcFrfYZRMrPSB4x+mmRGperxdRgP1xSpa1loMEpUYtXHC9Dhw7Re4Q9bD27aUxM23PS1BEtSpxgBxvyoY9PD85dUcB8DRr9Noo5eVUtzT52yo2sFjHOwfUlG7VkfRMny2n7RpjSDg1FYgkW24UPsyA9F2dFDBxgDUUfKXDseZNDSpgNdwBiGSfvkVOflrmP9xUUgFnFXhCBh5BslVY7trJtyxhpAScOEliqghVwoQTdMqh6drIBM0YmsW9RazGBh5BuWZClqh1vpuY4Rk4A4KKoqkuWhc+2pSlYUQVn5uu3WxdwwflslaxbqtG8kYMwmUz1J8Sco9myldY3cxq6tkq9ZKWxfxtzAyCDsqPTbshGUt7jrg+nnkiKPG8ynWThF7nm56Ot2crMpUcIusZPAINdN88KRenSAnaC7pkYlarZW7tWw62q+YAxxUyKlsI9g/mAMFEiAmFcYAwMSIOYdRgcxpG0bMIiHwO+85XapKu80sKXs7O9ny/qcNQPEIiljTCHJXbfV/BdoFXr3K8B4HsomQ5l1V68eu+UIuAHGh5Mbmq6jQepyBhC2MH4IGE2Vkpaz2tzigtSP4sE9KCFgTKqWVQazomRDQvHZmsOi7xcNGN0lPQvsyssXuN9hZlzEamwYDAaDwU27jxErMRd8LeaKq8JYUfHPP/nnANkzYJyVnBB8K/wzi3HGiOG8o0ZFswC0iDByxfWqsltvaMMIyFucdUdAL6cw8k6r4zHDEPZNIRgxHpkXGLnkv6mymU1xaeTO91gxB4AxPDe8pvwsD0tjA4aysOumS4FBuK2Kse1tV1lYsQgY3bjjtrI7hxySUu4WxX2XPIZwXGGzrYptFFM2iqCSlzwFkVzxCUAcGg1PHRThQksegthmRZyHW5RU3WQduzbyvlzhMc9iwwPlfqC6zwdtcNRToym8forzHs/qEJxL6sFUowuFzDWMJU9ArDKIC47go5mPzKblYtOQK5x42YAzXS+em7fjU6NLse+6GvuexonS8dlD+/VbUWFg5Ipr222OCbp9voGRB9dYVul78sgXdN28beNzxw9z1zWYpTnfJJtrmyMG0OtBY3brG0o+dWnej2cE4bayPzFAn9/SUPZyBaNFECNWjW5I8VFs6GrDAEozF+0OwougXLetlKWAQQwSwgRQ9jiTHswAMU2T0qRGKrkTLIwWQBzOTORtQTxDSd/Lauxhm90vcpSYdNT8ZqgopqxpKAdBwWihqefQPaDs6hyIPjS5GjCQ/SBgTJk1D9gl74LAMxDHTex9qd9MDWQpIxDT9N120ggeiugfkKUMQKQv7jThE2wWvANEn4xCqOUkMWQW04FJZ1a63NYOdt7ayu9GqGW+5+LZIqdVO7wnShUgWlPFugqjwr3KQ01+uOkUcSJAnOyeT1Q4630G7K6NRz5cKmMHIFq1TRXWwjN6eETDeE6Ukd2zdH8+Wh3XAHMTVTFpEjhvWzRVx0sOQBQ/EYqHb0DdVFVcCPTcjVlw4aalrYrH3f8xfDPZ1gI+9yYre7YwxhbWSwwD2rMzaC9ixYUnH1fVG1dU5dnL0rcazZvbHmeUJi2H2IZipq14IW9vPKPa714fAknW+/kP1fjoRzX4+z+Tt1PxRzczZeSlA1WpewZrM63qA4id95bOQBye1ItXhr+zqYw23bQ0aUGcaJZFz3W2Zeft5ydCV3/tquRaZl6HFTfNsaLkCe7BPRvZXEEkCEkVLV5LPwtllKriFjhzA+M4ycgCxO63v0v+5MwkLLUyxiqUTa2L7NmcLcmL2+9cV5v1xVFArhOLrS/uSoEZwnzw4cvDmHCa9X/7a/j3BXYtCzfdFL6+BcbcxHZjEMdQjWM9UyDpPUefvjpz6IZArL3/vWkmbayMNtz0hlAVI6Bj31ZuPD3R3ZokGqYgEtgJQDSyVDDycE5Z8Baooit/Hht2uQjIaZDR/512a0Ygrn92xwmINpRRMk3Vgyq6M3KdSVSPfkf/Nw3mOIguLS2MkuWRe0DGnVEyYQJk+eoTZ7+jrNsERILQAojHzmDkHQZMs70I44rJBM/0heQ6KZabBeTBB68Mf9KwzdEnZiBKM/J5KOOq4LUAMZmJwhoCsvHxD1NjOnLLJ5+/PnMqj/6GZRB7LmGsCl67D64S2GjJp2jKNLr/78xsN+6qp6msZUXsO4GR17eYZtERBrndKspFyUzS4RcTd59I4Q2WriZVRokqwkU7DvwnASlNOsYQWwbRmIGkMEpq7OCiM7iRF77xu/vGQDoE0ZgB18o4gItOHTdGaYA0GR9M49YNXXTfCYyxhupO4h3YhZZqjJaA3D08nfh/DkEkM551S6KMkrKmY3BkRR17aR/srS/vPgIk/dvl9B6rYtf0xUmqdpYEr4WLtqsw1bRAtr76VVWeu6yi+/8Mh4EyOGdjEy/i1276yPRL0fFiCQxZtJu3bTbkd209rYqi5k9J3LTp0wlVtG9UIR/CuqFEi+1EMPJaF7jo+cWOdJMbQTw0CXZCkCqjBMZ7oMdZMuPzEt9dSdKSBkZJJg1ldAdkV/lZqEy7aCVebCeFUdpDB+YOyB3PgCQQUym2FManoIzeAemDy26lBdGpm0a3iExd9rIS1j5azJobtnbJksIY4e57CWSfgcxynxx6CKgRqLWqLCmMpjVJKBvLHsgBJw+Ljr9/yuZrQ7dsYYu2uIlmSAR7utSy2mgcNsEe7LAq3Up4mhLuu9reVwwjAzlrmzA0ifcPTAKSalCrgri/rx5sfN6zrYJWYIwBOX7q4iffwirAIOAclwHGW+49WG/jUP2m2f8CDAD1bzaEOOLAWAAAAABJRU5ErkJggg=="/>
|
||||
</defs>
|
||||
</svg>
|
||||
<h2 class="result-title" th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입이 완료되었습니다.'">회원가입이 완료되었습니다.</h2>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>회원가입</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="account-recovery-page">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
<!-- Result Content Area -->
|
||||
<div class="account-recovery-result">
|
||||
<div class="result-header">
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<rect width="64" height="64" fill="url(#pattern0_1130_6799)" />
|
||||
<defs>
|
||||
<pattern id="pattern0_1130_6799" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_1130_6799" transform="translate(0 0.104294) scale(0.00613497)" />
|
||||
</pattern>
|
||||
<image id="image0_1130_6799" width="163" height="129" preserveAspectRatio="none"
|
||||
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKMAAACBCAYAAACsCAq9AAAACXBIWXMAABcSAAAXEgFnn9JSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACtpJREFUeNrsnUFoY1UUhm8GXYgwFmcxG2FSBZEBp6m60IU0AREGhCZrF003umzrxpW20Y1uTAtudJN04bodGBBEaLrShdoMwiAI9g24mYUQBXXhxnuSk87rTJPc8969L/e+9x94lOkkzXt53/vPOfeee65SMJgnVsJXcN6++emXsv5BR0UfCxNe1tfH4M2XXujhGwOMtuGr62NFH9UpAKopYNJxSx89DegAWAFGCYAEXFMfa6yANq2rj32oJmA0gbDNSrjg+OMifbQ0lF1gBhgfhnBTHxsZQHiRG9+CUgJGApHiwA4nJfO0XVZKxJRFhFGDuKN/bHt0SuS6GxrIPtArCIzslg84O/bR1hFLPmqXcgrikccgknVYtWF5VcYYiJVATrmrFXIdGOZMGQMEkaypz3sTGObPTdsCMeLMlxSrRodWL/Igi+N/U1asj0N92MiM2xrIJlDMiZvWN5MGstMoDEFFCcWeBi8SfjYNoNNMTj3l59eKnmWXcgBinTPnpBDskRKmHf/jOe5OisSpr89hGTCGHSeeqmSzKuRm120PQvPD0Ul4TjQovgMYi+WeaXpu19NkalEaKgDG+YNYZlWUWmYDzvocSSGlyQmVodWQTYdl2z6DSMZjiNLPq/J8OmAMSBWbPoMYDwnUqHJHYhuAMRyT3qzDec0Fc4LUULIxyTo/cIAxAJOoIkEw1yk3TkhawrfVAaP/Llpapb3lQw0hZ+8Sd70GGP23FcFrI89KtSTqWCmaqw4RRkmmuefTiesHgwbaI0fXChgzdtHkniUDyV0PL0PygKwARn9NAqKva5h7jq4XMHoM47GPF8CVORFgDB9GSRbd8/g6jONGDk0Ao4e25OKGz8Ekql0BjIErY1ErXwAjDAYYYYARBgOMoiy0jNsLGF3aPcFrfYZRMrPSB4x+mmRGperxdRgP1xSpa1loMEpUYtXHC9Dhw7Re4Q9bD27aUxM23PS1BEtSpxgBxvyoY9PD85dUcB8DRr9Noo5eVUtzT52yo2sFjHOwfUlG7VkfRMny2n7RpjSDg1FYgkW24UPsyA9F2dFDBxgDUUfKXDseZNDSpgNdwBiGSfvkVOflrmP9xUUgFnFXhCBh5BslVY7trJtyxhpAScOEliqghVwoQTdMqh6drIBM0YmsW9RazGBh5BuWZClqh1vpuY4Rk4A4KKoqkuWhc+2pSlYUQVn5uu3WxdwwflslaxbqtG8kYMwmUz1J8Sco9myldY3cxq6tkq9ZKWxfxtzAyCDsqPTbshGUt7jrg+nnkiKPG8ynWThF7nm56Ot2crMpUcIusZPAINdN88KRenSAnaC7pkYlarZW7tWw62q+YAxxUyKlsI9g/mAMFEiAmFcYAwMSIOYdRgcxpG0bMIiHwO+85XapKu80sKXs7O9ny/qcNQPEIiljTCHJXbfV/BdoFXr3K8B4HsomQ5l1V68eu+UIuAHGh5Mbmq6jQepyBhC2MH4IGE2Vkpaz2tzigtSP4sE9KCFgTKqWVQazomRDQvHZmsOi7xcNGN0lPQvsyssXuN9hZlzEamwYDAaDwU27jxErMRd8LeaKq8JYUfHPP/nnANkzYJyVnBB8K/wzi3HGiOG8o0ZFswC0iDByxfWqsltvaMMIyFucdUdAL6cw8k6r4zHDEPZNIRgxHpkXGLnkv6mymU1xaeTO91gxB4AxPDe8pvwsD0tjA4aysOumS4FBuK2Kse1tV1lYsQgY3bjjtrI7hxySUu4WxX2XPIZwXGGzrYptFFM2iqCSlzwFkVzxCUAcGg1PHRThQksegthmRZyHW5RU3WQduzbyvlzhMc9iwwPlfqC6zwdtcNRToym8forzHs/qEJxL6sFUowuFzDWMJU9ArDKIC47go5mPzKblYtOQK5x42YAzXS+em7fjU6NLse+6GvuexonS8dlD+/VbUWFg5Ipr222OCbp9voGRB9dYVul78sgXdN28beNzxw9z1zWYpTnfJJtrmyMG0OtBY3brG0o+dWnej2cE4bayPzFAn9/SUPZyBaNFECNWjW5I8VFs6GrDAEozF+0OwougXLetlKWAQQwSwgRQ9jiTHswAMU2T0qRGKrkTLIwWQBzOTORtQTxDSd/Lauxhm90vcpSYdNT8ZqgopqxpKAdBwWihqefQPaDs6hyIPjS5GjCQ/SBgTJk1D9gl74LAMxDHTex9qd9MDWQpIxDT9N120ggeiugfkKUMQKQv7jThE2wWvANEn4xCqOUkMWQW04FJZ1a63NYOdt7ayu9GqGW+5+LZIqdVO7wnShUgWlPFugqjwr3KQ01+uOkUcSJAnOyeT1Q4630G7K6NRz5cKmMHIFq1TRXWwjN6eETDeE6Ukd2zdH8+Wh3XAHMTVTFpEjhvWzRVx0sOQBQ/EYqHb0DdVFVcCPTcjVlw4aalrYrH3f8xfDPZ1gI+9yYre7YwxhbWSwwD2rMzaC9ixYUnH1fVG1dU5dnL0rcazZvbHmeUJi2H2IZipq14IW9vPKPa714fAknW+/kP1fjoRzX4+z+Tt1PxRzczZeSlA1WpewZrM63qA4id95bOQBye1ItXhr+zqYw23bQ0aUGcaJZFz3W2Zeft5ydCV3/tquRaZl6HFTfNsaLkCe7BPRvZXEEkCEkVLV5LPwtllKriFjhzA+M4ycgCxO63v0v+5MwkLLUyxiqUTa2L7NmcLcmL2+9cV5v1xVFArhOLrS/uSoEZwnzw4cvDmHCa9X/7a/j3BXYtCzfdFL6+BcbcxHZjEMdQjWM9UyDpPUefvjpz6IZArL3/vWkmbayMNtz0hlAVI6Bj31ZuPD3R3ZokGqYgEtgJQDSyVDDycE5Z8Baooit/Hht2uQjIaZDR/512a0Ygrn92xwmINpRRMk3Vgyq6M3KdSVSPfkf/Nw3mOIguLS2MkuWRe0DGnVEyYQJk+eoTZ7+jrNsERILQAojHzmDkHQZMs70I44rJBM/0heQ6KZabBeTBB68Mf9KwzdEnZiBKM/J5KOOq4LUAMZmJwhoCsvHxD1NjOnLLJ5+/PnMqj/6GZRB7LmGsCl67D64S2GjJp2jKNLr/78xsN+6qp6msZUXsO4GR17eYZtERBrndKspFyUzS4RcTd59I4Q2WriZVRokqwkU7DvwnASlNOsYQWwbRmIGkMEpq7OCiM7iRF77xu/vGQDoE0ZgB18o4gItOHTdGaYA0GR9M49YNXXTfCYyxhupO4h3YhZZqjJaA3D08nfh/DkEkM551S6KMkrKmY3BkRR17aR/srS/vPgIk/dvl9B6rYtf0xUmqdpYEr4WLtqsw1bRAtr76VVWeu6yi+/8Mh4EyOGdjEy/i1276yPRL0fFiCQxZtJu3bTbkd209rYqi5k9J3LTp0wlVtG9UIR/CuqFEi+1EMPJaF7jo+cWOdJMbQTw0CXZCkCqjBMZ7oMdZMuPzEt9dSdKSBkZJJg1ldAdkV/lZqEy7aCVebCeFUdpDB+YOyB3PgCQQUym2FManoIzeAemDy26lBdGpm0a3iExd9rIS1j5azJobtnbJksIY4e57CWSfgcxynxx6CKgRqLWqLCmMpjVJKBvLHsgBJw+Ljr9/yuZrQ7dsYYu2uIlmSAR7utSy2mgcNsEe7LAq3Up4mhLuu9reVwwjAzlrmzA0ifcPTAKSalCrgri/rx5sfN6zrYJWYIwBOX7q4iffwirAIOAclwHGW+49WG/jUP2m2f8CDAD1bzaEOOLAWAAAAABJRU5ErkJggg==" />
|
||||
</defs>
|
||||
</svg>
|
||||
<h2 class="result-title"
|
||||
th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입이 완료되었습니다.'">회원가입이 완료되었습니다.</h2>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="result-info-box info-box-highlight" th:if="${registrationType == 'corporate'}">
|
||||
<div class="highlight-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="#E8F1FF" stroke="#0049B4" stroke-width="2" />
|
||||
<path d="M12 8V13" stroke="#0049B4" stroke-width="2" stroke-linecap="round" />
|
||||
<circle cx="12" cy="16" r="1.25" fill="#0049B4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="info-text">
|
||||
<strong>관리자의 확인 및 승인</strong> 이후
|
||||
이용하실 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="result-info-box" th:if="${registrationType == 'corporate'}">
|
||||
<p class="info-text">
|
||||
관리자의 확인 및 승인 이후<br>
|
||||
이용하실 수 있습니다.
|
||||
</p>
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<a th:href="@{/}" class="submit-button">홈으로</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<a th:href="@{/}" class="submit-button">홈으로</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</body>
|
||||
<th:block layout:fragment="contentScript">
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
@@ -37,181 +37,244 @@
|
||||
<img th:src="@{/img/icon/icon_check_green.png}" alt="체크 아이콘" />
|
||||
</div>
|
||||
<ul class="signup-info-list">
|
||||
<li>회원은 법인, 개인(개발자)분으로 구분되며, 개발자 포털에서 온라인으로 회원 가입 신청을 합니다.</li>
|
||||
<li>포털 사용은 운영 담당자의 승인 후 가능합니다.</li>
|
||||
<li>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 직원(개발자)을 추가 등록해 주시기 바랍니다.</li>
|
||||
<li>회원은 <strong>법인, 개인(개발자)</strong>분으로 구분되며, 개발자 포털에서 온라인으로 회원 가입 신청을 합니다.</li>
|
||||
<li>포털 사용은 <strong>운영 담당자의 승인 후</strong> 가능합니다.</li>
|
||||
<li>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 <strong>직원(개발자)을 추가 등록</strong>해 주시기 바랍니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Vertical Timeline Steps -->
|
||||
<div class="signup-timeline">
|
||||
<!-- Step 1 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box one">
|
||||
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
||||
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path
|
||||
d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z"
|
||||
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M20 21V20C20 18.8954 20.8954 18 22 18C23.1046 18 24 18.8954 24 20V21"
|
||||
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path
|
||||
d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z"
|
||||
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg> */-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-person-add" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
|
||||
<path
|
||||
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-num">STEP 01</div>
|
||||
<div class="signup-step__card-title">회원가입</div>
|
||||
<!-- Member Type Tabs -->
|
||||
<div class="signup-tabs">
|
||||
<button type="button" class="signup-tab-btn active" data-tab="personal">개인 회원</button>
|
||||
<button type="button" class="signup-tab-btn" data-tab="corporate">법인 회원</button>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Container for fixed height spacing -->
|
||||
<div class="signup-timeline-container">
|
||||
<!-- Personal Member Timeline -->
|
||||
<div class="signup-timeline active" id="timeline-personal">
|
||||
<!-- Step 1 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box one">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-person-add" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
|
||||
<path
|
||||
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-header">
|
||||
<span class="signup-step__card-num">STEP 01</span>
|
||||
<span class="signup-step__card-dot"></span>
|
||||
<span class="signup-step__card-title">회원가입</span>
|
||||
</div>
|
||||
<p class="signup-step__card-desc">개발자 포털을 이용하기 위해 회원가입을 진행합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box two">
|
||||
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
||||
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path
|
||||
d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z"
|
||||
stroke="#0049B4" stroke -width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg> */-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-journal-check" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10.854 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 8.793l2.646-2.647a.5.5 0 0 1 .708 0" />
|
||||
<path
|
||||
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2" />
|
||||
<path
|
||||
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z" />
|
||||
</svg>
|
||||
<!-- Corporate Member Timeline -->
|
||||
<div class="signup-timeline" id="timeline-corporate">
|
||||
<!-- Step 1 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box one">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-person-add" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
|
||||
<path
|
||||
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-header">
|
||||
<span class="signup-step__card-num">STEP 01</span>
|
||||
<span class="signup-step__card-dot"></span>
|
||||
<span class="signup-step__card-title">회원가입</span>
|
||||
</div>
|
||||
<p class="signup-step__card-desc">개발자 포털을 이용하기 위해 회원가입을 진행합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-num">STEP 02</div>
|
||||
<div class="signup-step__card-title">승인</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box three">
|
||||
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg> */-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-code" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8z" />
|
||||
</svg>
|
||||
<!-- Step 2 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box two">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-journal-check" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10.854 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 8.793l2.646-2.647a.5.5 0 0 1 .708 0" />
|
||||
<path
|
||||
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2" />
|
||||
<path
|
||||
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-header">
|
||||
<span class="signup-step__card-num">STEP 02</span>
|
||||
<span class="signup-step__card-dot"></span>
|
||||
<span class="signup-step__card-title">승인</span>
|
||||
</div>
|
||||
<p class="signup-step__card-desc">신청하신 계정 정보를 관리자가 확인 후 승인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-num">STEP 03</div>
|
||||
<div class="signup-step__card-title">API/APP 사용 신청</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box four">
|
||||
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg> */-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-display" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4q0 1 .25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75Q6 13 6 12H2s-2 0-2-2zm1.398-.855a.76.76 0 0 0-.254.302A1.5 1.5 0 0 0 1 4.01V10c0 .325.078.502.145.602q.105.156.302.254a1.5 1.5 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.76.76 0 0 0 .254-.302 1.5 1.5 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.76.76 0 0 0-.302-.254A1.5 1.5 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145" />
|
||||
</svg>
|
||||
<!-- Step 3 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box three">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-code" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-header">
|
||||
<span class="signup-step__card-num">STEP 03</span>
|
||||
<span class="signup-step__card-dot"></span>
|
||||
<span class="signup-step__card-title">API / APP 사용 신청</span>
|
||||
</div>
|
||||
<p class="signup-step__card-desc">사용할 API를 선택하고 테스트용 키를 발급받습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-num">STEP 04</div>
|
||||
<div class="signup-step__card-title">개발/테스트</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box five">
|
||||
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"*/-->
|
||||
<!-- xmlns="http://www.w3.org/2000/svg">-->
|
||||
<!--/* <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"*/-->
|
||||
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
||||
<!--/* <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />*/-->
|
||||
<!--/* <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"*/-->
|
||||
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
||||
<!--/* </svg>*/-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M2 10h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1m9-9h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1m0 9a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm0-10a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM2 9a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2zm7 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2zM0 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5.354.854a.5.5 0 1 0-.708-.708L3 3.793l-.646-.647a.5.5 0 1 0-.708.708l1 1a.5.5 0 0 0 .708 0z" />
|
||||
</svg>
|
||||
<!-- Step 4 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box four">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-display" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4q0 1 .25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75Q6 13 6 12H2s-2 0-2-2zm1.398-.855a.76.76 0 0 0-.254.302A1.5 1.5 0 0 0 1 4.01V10c0 .325.078.502.145.602q.105.156.302.254a1.5 1.5 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.76.76 0 0 0 .254-.302 1.5 1.5 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.76.76 0 0 0-.302-.254A1.5 1.5 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-header">
|
||||
<span class="signup-step__card-num">STEP 04</span>
|
||||
<span class="signup-step__card-dot"></span>
|
||||
<span class="signup-step__card-title">개발 / 테스트</span>
|
||||
</div>
|
||||
<p class="signup-step__card-desc">제공된 개발 도구로 자유롭게 연동을 테스트합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-num">STEP 05</div>
|
||||
<div class="signup-step__card-title">운영신청 및 심사</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 6 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box six">
|
||||
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M7 13H25" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M12 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg> */-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-airplane" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6.428 1.151C6.708.591 7.213 0 8 0s1.292.592 1.572 1.151C9.861 1.73 10 2.431 10 3v3.691l5.17 2.585a1.5 1.5 0 0 1 .83 1.342V12a.5.5 0 0 1-.582.493l-5.507-.918-.375 2.253 1.318 1.318A.5.5 0 0 1 10.5 16h-5a.5.5 0 0 1-.354-.854l1.319-1.318-.376-2.253-5.507.918A.5.5 0 0 1 0 12v-1.382a1.5 1.5 0 0 1 .83-1.342L6 6.691V3c0-.568.14-1.271.428-1.849m.894.448C7.111 2.02 7 2.569 7 3v4a.5.5 0 0 1-.276.447l-5.448 2.724a.5.5 0 0 0-.276.447v.792l5.418-.903a.5.5 0 0 1 .575.41l.5 3a.5.5 0 0 1-.14.437L6.708 15h2.586l-.647-.646a.5.5 0 0 1-.14-.436l.5-3a.5.5 0 0 1 .576-.411L15 11.41v-.792a.5.5 0 0 0-.276-.447L9.276 7.447A.5.5 0 0 1 9 7V3c0-.432-.11-.979-.322-1.401C8.458 1.159 8.213 1 8 1s-.458.158-.678.599" />
|
||||
</svg>
|
||||
<!-- Step 5 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box five">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M2 10h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1m9-9h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1m0 9a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm0-10a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM2 9a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2zm7 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2zM0 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5.354.854a.5.5 0 1 0-.708-.708L3 3.793l-.646-.647a.5.5 0 1 0-.708.708l1 1a.5.5 0 0 0 .708 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-header">
|
||||
<span class="signup-step__card-num">STEP 05</span>
|
||||
<span class="signup-step__card-dot"></span>
|
||||
<span class="signup-step__card-title">운영신청 및 심사</span>
|
||||
</div>
|
||||
<p class="signup-step__card-desc">실운영 환경 적용을 위해 최종 심사를 요청합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-num">STEP 06</div>
|
||||
<div class="signup-step__card-title">서비스 개시(예약)</div>
|
||||
|
||||
<!-- Step 6 -->
|
||||
<div class="signup-step">
|
||||
<div class="signup-step__icon-box six">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-airplane" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6.428 1.151C6.708.591 7.213 0 8 0s1.292.592 1.572 1.151C9.861 1.73 10 2.431 10 3v3.691l5.17 2.585a1.5 1.5 0 0 1 .83 1.342V12a.5.5 0 0 1-.582.493l-5.507-.918-.375 2.253 1.318 1.318A.5.5 0 0 1 10.5 16h-5a.5.5 0 0 1-.354-.854l1.319-1.318-.376-2.253-5.507.918A.5.5 0 0 1 0 12v-1.382a1.5 1.5 0 0 1 .83-1.342L6 6.691V3c0-.568.14-1.271.428-1.849m.894.448C7.111 2.02 7 2.569 7 3v4a.5.5 0 0 1-.276.447l-5.448 2.724a.5.5 0 0 0-.276.447v.792l5.418-.903a.5.5 0 0 1 .575.41l.5 3a.5.5 0 0 1-.14.437L6.708 15h2.586l-.647-.646a.5.5 0 0 1-.14-.436l.5-3a.5.5 0 0 1 .576-.411L15 11.41v-.792a.5.5 0 0 0-.276-.447L9.276 7.447A.5.5 0 0 1 9 7V3c0-.432-.11-.979-.322-1.401C8.458 1.159 8.213 1 8 1s-.458.158-.678.599" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="signup-step__card">
|
||||
<div class="signup-step__card-header">
|
||||
<span class="signup-step__card-num">STEP 06</span>
|
||||
<span class="signup-step__card-dot"></span>
|
||||
<span class="signup-step__card-title">서비스 개시(예약)</span>
|
||||
</div>
|
||||
<p class="signup-step__card-desc">실운영 키를 적용하여 서비스를 정식 오픈합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 회원 구분별 이용 가능 서비스 (TBD) -->
|
||||
<section class="signup-roles-tbd">
|
||||
<h2 class="signup-roles-tbd__title">회원 구분별 이용 가능 서비스</h2>
|
||||
<div class="signup-roles-tbd__placeholder">
|
||||
<span class="signup-roles-tbd__badge">TBD</span>
|
||||
<p class="signup-roles-tbd__desc">회원 구분별 이용 가능 서비스 안내가 준비 중입니다.</p>
|
||||
<!-- 회원 구분별 이용 가능 서비스 -->
|
||||
<section class="signup-roles-table-section">
|
||||
<h2 class="signup-roles-table-section__title">회원 구분별 이용 가능 서비스</h2>
|
||||
<div class="signup-roles-table-wrapper">
|
||||
<table class="signup-roles-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" class="col-feature">기능</th>
|
||||
<th rowspan="2" class="col-role">비회원</th>
|
||||
<th rowspan="2" class="col-role">개인</th>
|
||||
<th colspan="2" class="col-role-group">법인이용자</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="col-sub-role">법인개발자</th>
|
||||
<th class="col-sub-role">법인관리자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="cell-feature">API 조회</td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="cell-feature">API 테스트</td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="cell-feature">1:1 문의</td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="cell-feature">피드백/개선요청</td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="cell-feature">클라이언트 - 신규, 수정, 삭제</td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="cell-feature">Webhook 관리</td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="cell-feature">이용 통계</td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="cell-feature">개발자 관리</td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--denied">X</span></td>
|
||||
<td><span class="status-badge status-badge--allowed">O</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -225,6 +288,31 @@
|
||||
</body>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const tabs = document.querySelectorAll('.signup-tab-btn');
|
||||
const timelines = document.querySelectorAll('.signup-timeline');
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', function () {
|
||||
const targetTab = this.getAttribute('data-tab');
|
||||
|
||||
// Active Tab toggle
|
||||
tabs.forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Timeline display toggle
|
||||
timelines.forEach(timeline => {
|
||||
if (timeline.id === `timeline-${targetTab}`) {
|
||||
timeline.classList.add('active');
|
||||
} else {
|
||||
timeline.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
@@ -6,34 +6,7 @@
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="service-intro">
|
||||
<h1 class="service-intro__title">DJBank 개발자포탈 소개</h1>
|
||||
|
||||
<p class="service-intro__lead">기업의 업무 흐름 속으로, 금융이 흘러 들어갑니다.</p>
|
||||
|
||||
<p class="service-intro__desc">
|
||||
제주은행의 디지털 기업금융 특화 브랜드 'DJ Bank'의 금융 서비스를<br>
|
||||
핀테크 기업·개발자·ERP/SaaS 파트너가 손쉽게 연결해 활용할 수 있도록 지원하는<br>
|
||||
통합 Open API 포털입니다.
|
||||
</p>
|
||||
|
||||
<div class="service-intro__spacer" aria-hidden="true"></div>
|
||||
|
||||
<h2 class="service-intro__section-title">구축 배경</h2>
|
||||
<div class="service-intro__section-body">
|
||||
<p>기존 기업금융은 서류 부담, 시간 지연, 대면중심 절차의 한계가 있었습니다. 또한 폐쇄적인 금융시스템 안에서 핀테크·SaaS 기업이 금융 기능을 자사 서비스에 결합하기 어려웠습니다.</p>
|
||||
<p>DJ Bank는 ERP 데이터와 AI를 결합한 자율형 금융 플랫폼을 지향하며, 이 비전을 실현하기 위해 개방형 API 생태계를 구축했습니다. 기업이 본업의 흐름을 끊지 않고 금융을 이용할 수 있도록, 핵심 금융 자원을 파트너와 개발자에게 개방합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="service-intro__spacer" aria-hidden="true"></div>
|
||||
|
||||
<h2 class="service-intro__section-title">제공 API 카테고리</h2>
|
||||
<ul class="service-intro__list">
|
||||
<li>계좌 · 수신 - 법인 계좌개설·조회, 법인 파킹통장 연계</li>
|
||||
<li>여신 · 대출 - ERP 연계 매출채권 담보대출, AX 솔루션자금</li>
|
||||
<li>신용 · 평가 - 대안신용평가 전략모형 기반 기업 신용 조회</li>
|
||||
<li>이체 · 결제 - 펌뱅킹, 실시간이체, 결제 API</li>
|
||||
<li>인증 · 보안 - OAuth 2.0, 전자서명, 마이데이터 인증</li>
|
||||
</ul>
|
||||
<h1 class="service-intro__title">DJBank API Portal 소개</h1>
|
||||
</section>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
@@ -358,7 +358,7 @@ valid = constantTimeEquals(received, expected)</pre>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ERROR_START</code></td>
|
||||
<td>장애 시작</td>
|
||||
<td>장애 발생</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ERROR_END</code></td>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<!-- <link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}" /> -->
|
||||
<div class="service-main app-management-layout">
|
||||
<div class="service-main container" style="padding-top: 70px;">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('statistics')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<section class="api-statistics-container">
|
||||
@@ -24,7 +24,8 @@
|
||||
<span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span>
|
||||
<span class="statistics-notice-example" th:if="${statsAggregationMinute != null}"
|
||||
th:text="'예) 매시 ' + ${statsAggregationMinute} + '분 집계 기준 — 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.'">예시</span>
|
||||
<span class="statistics-notice-example" th:if="${statsAggregationMinute == null}">예) 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.</span>
|
||||
<span class="statistics-notice-example" th:if="${statsAggregationMinute == null}">예) 현재 12:30이면 12:00 이전,
|
||||
12:05이면 11:00 이전에 발생한 거래가 대상입니다.</span>
|
||||
<span th:if="${statsLatestTime != null}" class="statistics-notice-latest"
|
||||
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
</section>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="signup-guide-v2 figma-register-wrapper">
|
||||
<div class="service-main app-management-layout">
|
||||
<div class="signup-guide-v2">
|
||||
<div class="service-main container" style="padding-top: 70px;">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
|
||||
@@ -22,9 +22,13 @@
|
||||
<!-- Content Area -->
|
||||
<div class="app-management-content">
|
||||
<div class="step1-wrap">
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="s1-title">Webhook 관리</h2>
|
||||
<!-- Header Row with Title and Dev Guide Link -->
|
||||
<div class="webhook-header-row">
|
||||
<h2 class="s1-title">Webhook 관리</h2>
|
||||
<a class="webhook-guide-btn" th:href="@{/service/webhook-dev-guide}">
|
||||
웹훅 개발가이드 - 수신.서명검증.응답 규칙 보러가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Empty Card -->
|
||||
<div class="s1-form-card">
|
||||
@@ -38,18 +42,11 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 -->
|
||||
<div class="s1-actions" sec:authorize="hasRole('ROLE_WEBHOOK')">
|
||||
<button type="button" class="s1-btn-next" id="requestWebhook">Webhook 신청</button>
|
||||
<button type="button" class="s1-btn-next-figma" id="requestWebhook">Webhook 신청</button>
|
||||
</div>
|
||||
|
||||
<p class="webhook-guide-link">
|
||||
<a th:href="@{/service/webhook-dev-guide}">
|
||||
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</div><!-- /step1-wrap -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user