feat(frontend): 포탈·운영·관리자 콘솔 및 매뉴얼·거래 타임라인·전문탭·서비스 제어 UI
- 콘솔을 포탈/운영/관리자 3뷰로 분리(Portal·AdminPanel·AdminLogin·auth·util), ADMIN JWT 로그인 게이트 - 상단 우측 '서비스 매뉴얼'(public/manual.html) 링크, 로그인 화면 불필요 계좌조회 에러 제거 - 거래 조회: 서비스별 처리시각 5단계 타임라인 + pacs.008/pacs.002 전문 탭(/notifications 프록시) - 관리자: 서비스 제어 패널(기동/중지/재기동/로그, 4초 폴링, 인프라 상태 포함) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
334
frontend/public/manual.html
Normal file
334
frontend/public/manual.html
Normal file
@@ -0,0 +1,334 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RTGS 포탈 · 서비스 매뉴얼</title>
|
||||
<style>
|
||||
:root {
|
||||
--gucci: #6b4fbb; --chanel: #0b63c4; --hermes: #c77d0a;
|
||||
--dior: #8a4fd0; --prada: #b8860b; --lv: #1a7f37; --seq: #444;
|
||||
--ink: #222; --muted: #666; --line: #e2e2e2; --bg: #f4f7fb;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
color: var(--ink); line-height: 1.65; margin: 0;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.wrap { max-width: 960px; margin: 0 auto; padding: 32px 24px 80px; }
|
||||
header.doc {
|
||||
background: linear-gradient(135deg, #1a2a44, #2c4468);
|
||||
color: #fff; border-radius: 12px; padding: 28px 30px; margin-bottom: 8px;
|
||||
}
|
||||
header.doc h1 { margin: 0 0 6px; font-size: 26px; }
|
||||
header.doc p { margin: 0; color: #cdd8ea; font-size: 14px; }
|
||||
.toc {
|
||||
background: #fff; border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 16px 22px; margin: 20px 0 8px; font-size: 14px;
|
||||
}
|
||||
.toc b { display: block; margin-bottom: 8px; color: var(--muted); font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.toc a { color: var(--chanel); text-decoration: none; margin-right: 16px; white-space: nowrap; line-height: 2; }
|
||||
.toc a:hover { text-decoration: underline; }
|
||||
h2 {
|
||||
font-size: 20px; margin: 40px 0 14px; padding-bottom: 8px;
|
||||
border-bottom: 2px solid #eceff3;
|
||||
}
|
||||
h3 { font-size: 16px; margin: 22px 0 8px; }
|
||||
p, li { font-size: 15px; }
|
||||
code {
|
||||
background: #eef1f5; padding: 1px 6px; border-radius: 4px;
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px;
|
||||
}
|
||||
.lead { color: var(--muted); font-size: 15px; }
|
||||
|
||||
/* 상태 범례 */
|
||||
.legend { display: flex; gap: 20px; flex-wrap: wrap; background: var(--bg); border-radius: 10px; padding: 14px 18px; margin: 12px 0; }
|
||||
.legend div { font-size: 14px; }
|
||||
|
||||
/* 흐름도 */
|
||||
.flow { display: flex; align-items: stretch; gap: 4px; flex-wrap: wrap; margin: 14px 0; }
|
||||
.flow .node {
|
||||
flex: 1; min-width: 92px; border: 2px solid #ccd3dd; border-radius: 8px;
|
||||
background: #fff; text-align: center; padding: 10px 6px; font-size: 13px;
|
||||
}
|
||||
.flow .node small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
|
||||
.flow .arrow { align-self: center; color: #99a; font-size: 18px; flex: 0 0 auto; }
|
||||
|
||||
/* 서비스 카드 */
|
||||
.svc {
|
||||
border: 1px solid var(--line); border-top: 5px solid var(--bar, #888);
|
||||
border-radius: 10px; padding: 20px 22px; margin: 18px 0;
|
||||
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
}
|
||||
.svc h3 { margin-top: 0; font-size: 18px; display: flex; align-items: center; gap: 8px; }
|
||||
.svc .tag {
|
||||
font-size: 11px; font-weight: 600; color: #fff; background: var(--bar, #888);
|
||||
padding: 2px 9px; border-radius: 20px; margin-left: auto;
|
||||
}
|
||||
.svc .sub { color: var(--muted); font-size: 13px; margin: -4px 0 12px; }
|
||||
.meta-row { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 13px; color: #555; margin-bottom: 10px; }
|
||||
.meta-row span b { color: var(--ink); }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 14px; }
|
||||
th, td { border: 1px solid #e6e9ee; padding: 7px 11px; text-align: left; vertical-align: top; }
|
||||
th { background: #f4f7fb; font-weight: 600; }
|
||||
td code { white-space: nowrap; }
|
||||
|
||||
.metrics th:first-child, .metrics td:first-child { width: 34%; }
|
||||
.note { background: #fffbe9; border: 1px solid #f0e2a8; border-radius: 8px; padding: 12px 16px; font-size: 14px; margin: 14px 0; }
|
||||
.btn-chip { display: inline-block; border: 1px solid #ccc; border-radius: 6px; padding: 2px 10px; font-size: 13px; background: #fff; margin: 2px 4px 2px 0; }
|
||||
footer { margin-top: 50px; padding-top: 18px; border-top: 1px solid var(--line); color: #999; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="doc">
|
||||
<h1>🏛 RTGS 포탈 서비스 매뉴얼</h1>
|
||||
<p>소액 RTGS(실시간총액결제) 프로토타입 · 7개 서비스 안내 · Active-Active-Active 복원력 설계</p>
|
||||
</header>
|
||||
|
||||
<nav class="toc">
|
||||
<b>목차</b>
|
||||
<a href="#intro">1. 포탈 개요</a>
|
||||
<a href="#read">2. 화면 읽는 법</a>
|
||||
<a href="#flow">3. 업무 흐름</a>
|
||||
<a href="#services">4. 서비스별 설명</a>
|
||||
<a href="#status">5. 거래 상태(TxSts)</a>
|
||||
<a href="#ports">6. 포트 일람</a>
|
||||
<a href="#glossary">7. 용어</a>
|
||||
</nav>
|
||||
|
||||
<h2 id="intro">1. 포탈 개요</h2>
|
||||
<p class="lead">
|
||||
본 포탈은 소액 <b>RTGS(Real-Time Gross Settlement, 실시간총액결제)</b> 프로토타입의 운영·관리 콘솔입니다.
|
||||
거래는 <b>단일 전역 순번기</b>가 입구에서 순번을 부여하고, 각 센터가 그 순번대로 동일하게 재생(결정론적 처리)하며,
|
||||
<b>원장(PostgreSQL)</b>이 잔액을 강한 일관성으로 확정합니다. 3센터 <b>Active-Active-Active</b> 구성에서
|
||||
정족수(2/3)와 펜싱으로 split-brain을 차단하는 것이 설계의 핵심입니다.
|
||||
</p>
|
||||
<p>
|
||||
시스템은 명품 브랜드를 코드네임으로 쓰는 <b>7개 서비스</b>로 구성됩니다. 포탈 화면에는 이 중
|
||||
주요 6개가 카드로 표시되고, 전역 순번기(Sequencer)는 업무 흐름도의 진입 노드로 나타납니다.
|
||||
각 서비스가 무슨 일을 하고, 카드에 표시되는 숫자가 무엇을 뜻하는지 이 문서에서 설명합니다.
|
||||
</p>
|
||||
|
||||
<h2 id="read">2. 화면 읽는 법</h2>
|
||||
<h3>상태 표시등</h3>
|
||||
<div class="legend">
|
||||
<div>🟢 <b>UP</b> — 정상 가동(최근 하트비트 수신)</div>
|
||||
<div>🟡 <b>STALE</b> — 하트비트 지연(응답이 오래됨, 점검 필요)</div>
|
||||
<div>🔴 <b>DOWN</b> — 중단(하트비트 없음/도달 불가)</div>
|
||||
</div>
|
||||
<p>
|
||||
상단 바의 <code>● n/7 UP</code>은 7개 서비스 중 정상 가동 중인 수를 나타냅니다.
|
||||
각 서비스는 DB의 <code>service_heartbeat</code>에 주기적으로 살아있음을 기록하고(헤드리스 포함),
|
||||
관리 서비스가 이를 읽어 UP/STALE/DOWN을 판정합니다.
|
||||
</p>
|
||||
<h3>자동 갱신 · 새로고침</h3>
|
||||
<p>
|
||||
<span class="btn-chip">자동갱신 5s</span> 체크 시 5초마다 상태·지표를 다시 불러옵니다.
|
||||
<span class="btn-chip">새로고침</span>으로 즉시 갱신할 수도 있습니다.
|
||||
카드의 처리 건수는 <b>DB 집계(원장)</b> 기준이라 관리자 콘솔에서 초기화하면 자연히 0으로 돌아갑니다.
|
||||
</p>
|
||||
|
||||
<h2 id="flow">3. 업무 흐름</h2>
|
||||
<p>한 건의 이체는 아래 순서로 처리됩니다. 각 노드는 담당 서비스이며, 포탈 흐름도에서 색 테두리로 실시간 상태를 함께 보여줍니다.</p>
|
||||
<div class="flow">
|
||||
<div class="node">🔢 Sequencer<small>전역순번</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">🛡️ Gucci<small>관문 인증</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">📥 Chanel<small>접수</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">🔄 Dior<small>동기화</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">💰 Hermes<small>결제</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">✅ Prada<small>처리동기화</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">📥 Chanel<small>결과통보</small></div>
|
||||
</div>
|
||||
<p class="lead">
|
||||
전역순번(Sequencer) 부여 → 관문 인증(Gucci) → 접수(Chanel) → 저널/센터 동기화(Dior) →
|
||||
결제·원장(Hermes) → 처리 동기화·완결(Prada) → 결과통보(Chanel).
|
||||
</p>
|
||||
|
||||
<h2 id="services">4. 서비스별 설명</h2>
|
||||
|
||||
<!-- Sequencer -->
|
||||
<div class="svc" style="--bar: var(--seq)">
|
||||
<h3>🔢 Sequencer <span class="tag">전역 순번기 · Headless</span></h3>
|
||||
<div class="sub">전역순번 부여 · 결정론적 값 확정 → 저널 발행</div>
|
||||
<div class="meta-row"><span>논리포트 <b>8090</b></span><span>유형 <b>Headless JVM</b></span><span>Kafka: <code>rtgs.inbound</code> → <code>rtgs.journal</code></span></div>
|
||||
<p>
|
||||
시스템의 <b>단일 진입 순서 결정자</b>입니다. 접수된 요청에 전역순번(<code>global_seq</code>)을 부여해
|
||||
단일 파티션 저널에 발행하면, 모든 센터가 이 순서를 그대로 재생해 동일한 결과를 만듭니다(결정론적 처리).
|
||||
재기동 시 <code>MAX(global_seq)</code>에서 이어서 발번하므로 순번 유실이 없습니다.
|
||||
</p>
|
||||
<p class="lead">※ 순서 결정만 담당(상태 O). 분산·라우팅은 Gucci/GSLB(상태 X)가 맡아 역할을 분리합니다.
|
||||
포탈에서는 별도 카드 없이 흐름도의 진입 노드로 표시됩니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- Gucci -->
|
||||
<div class="svc" style="--bar: var(--gucci)">
|
||||
<h3>🛡️ Gucci <span class="tag">게이트웨이 · 기관 인증</span></h3>
|
||||
<div class="sub">외부 경계 관문 — 로그인 · JWT · API인증 · 유량제어 · 재전송차단 · 결과 콜백송부</div>
|
||||
<div class="meta-row"><span>논리포트 <b>8095</b></span><span>유형 <b>Web(Tomcat)</b></span><span>코드네임 <b>Global User Communication Control Interface</b></span></div>
|
||||
<p>
|
||||
참가기관과 시스템 사이의 <b>대문</b>입니다. 로그인 후 JWT(HS256)를 발급하고, 모든 요청의 인증·인가
|
||||
(역할 + 기관 바인딩)를 검사합니다. 기관별 유량제어로 코어·순번기를 보호하고(백프레셔),
|
||||
nonce+timestamp로 재전송을 차단합니다. 처리 결과는 기관 콜백 URL로 재시도·ACK 방식으로 송부합니다.
|
||||
</p>
|
||||
<table class="metrics">
|
||||
<tr><th>카드 지표</th><th>의미</th></tr>
|
||||
<tr><td>코어(샤넬)</td><td>Gucci가 바라본 코어(Chanel) 연결 상태</td></tr>
|
||||
<tr><td>등록기관</td><td>당좌계좌가 등록된 참가기관 수</td></tr>
|
||||
</table>
|
||||
<div>바로가기 <span class="btn-chip">감사로그(Kibana)</span> — 인증·유량·재전송 등 감사 이벤트를 Kibana에서 조회</div>
|
||||
</div>
|
||||
|
||||
<!-- Chanel -->
|
||||
<div class="svc" style="--bar: var(--chanel)">
|
||||
<h3>📥 Chanel <span class="tag">접수</span></h3>
|
||||
<div class="sub">pacs.008 접수 · XSD검증 · 경량화 · 원전문 저장 · 결과통보(pacs.002)</div>
|
||||
<div class="meta-row"><span>논리포트 <b>8091</b></span><span>유형 <b>Web(Tomcat)</b></span><span>코드네임 <b>Contact Hub · Entry Liaison</b></span></div>
|
||||
<p>
|
||||
참가기관의 결제의뢰 전문(<b>ISO 20022 pacs.008</b>)을 받는 <b>접수 창구</b>입니다.
|
||||
공식 XSD로 검증(XXE 차단)하고 원전문을 SHA-256 해시와 함께 저장한 뒤, 경량 명령문으로 만들어
|
||||
입구 토픽에 발행합니다. 처리가 끝나면 <b>pacs.002</b> 결과통보 전문을 생성해 아웃박스에 적재합니다.
|
||||
</p>
|
||||
<table class="metrics">
|
||||
<tr><th>카드 지표</th><th>의미</th></tr>
|
||||
<tr><td>누적 접수</td><td>지금까지 접수된 거래 건수(원장 집계)</td></tr>
|
||||
<tr><td>접수율</td><td>초당 접수 건수(TPS, 최근 갱신 구간 기준)</td></tr>
|
||||
</table>
|
||||
<div>바로가기 <span class="btn-chip">이체 신청/조회</span> — 운영 콘솔에서 pacs.008 신청 및 거래 상태 조회</div>
|
||||
</div>
|
||||
|
||||
<!-- Dior -->
|
||||
<div class="svc" style="--bar: var(--dior)">
|
||||
<h3>🔄 Dior <span class="tag">센터간 접수 동기화</span></h3>
|
||||
<div class="sub">저널 소비 → 원장 PDNG 기록(접수 동기화)</div>
|
||||
<div class="meta-row"><span>논리포트 <b>8092</b></span><span>유형 <b>Headless JVM</b></span><span>코드네임 <b>Data Integration · Relay</b></span></div>
|
||||
<p>
|
||||
전역순번 저널을 소비해 원장에 거래를 <b>PDNG(대기)</b> 상태로 기록합니다. 결제(Hermes)와 병행해
|
||||
저널을 읽으면서 <b>모든 센터의 접수 상태를 동일하게 맞추는</b> 동기화 역할을 합니다.
|
||||
</p>
|
||||
<table class="metrics">
|
||||
<tr><th>카드 지표</th><th>의미</th></tr>
|
||||
<tr><td>메모리</td><td>프로세스 사용 메모리(MB) — 런타임 자원 지표</td></tr>
|
||||
<tr><td>가동</td><td>프로세스 가동 시간(초)</td></tr>
|
||||
</table>
|
||||
<div>바로가기 <span class="btn-chip">원장 보기</span> — 관리자 콘솔의 원장/거래 현황</div>
|
||||
</div>
|
||||
|
||||
<!-- Hermes -->
|
||||
<div class="svc" style="--bar: var(--hermes)">
|
||||
<h3>💰 Hermes <span class="tag">결제 · 원장 엔진</span></h3>
|
||||
<div class="sub">저널 순차소비 · 순서교정 · 선(先)저널 · 당좌 차/대변(강한 일관성) · ACSP</div>
|
||||
<div class="meta-row"><span>논리포트 <b>8093</b></span><span>유형 <b>Headless JVM</b></span><span>코드네임 <b>Rapid Memory Execution · Settlement</b></span></div>
|
||||
<p>
|
||||
실제 <b>잔액을 움직이는 결제 엔진</b>입니다. 저널을 순번대로(동시성 1) 소비하며 순서를 교정하고,
|
||||
잔액 연산 전에 <b>선저널(write-ahead)</b>로 순번을 확정한 뒤 송신·수신 기관 당좌계좌를 차/대변 처리합니다
|
||||
(PostgreSQL 강한 일관성 트랜잭션 → 이중지급 0·총액 보존). 정산이 반영되면 <b>ACSP</b> 결과를 발행합니다.
|
||||
선저널 덕분에 엔진이 죽었다 살아나도 Kafka 오프셋 재생으로 무손실 승계됩니다.
|
||||
</p>
|
||||
<table class="metrics">
|
||||
<tr><th>카드 지표</th><th>의미</th></tr>
|
||||
<tr><td>결제완료(ACCC)</td><td>최종 완결된 거래 건수(원장 집계)</td></tr>
|
||||
<tr><td>평균 처리</td><td>정산 1건당 평균 소요시간(ms) — 런타임 지표</td></tr>
|
||||
<tr><td>미결(대기)</td><td>접수됐으나 아직 완결·반려되지 않은 건수(전체 − ACCC − RJCT)</td></tr>
|
||||
</table>
|
||||
<div>바로가기 <span class="btn-chip">원장 보기</span> — 관리자 콘솔의 원장/잔액</div>
|
||||
</div>
|
||||
|
||||
<!-- Prada -->
|
||||
<div class="svc" style="--bar: var(--prada)">
|
||||
<h3>✅ Prada <span class="tag">센터간 처리 동기화 · 완결</span></h3>
|
||||
<div class="sub">결과 소비 · 완결 규율(과반) · ACCC 확정 · 조회사본 · 결과통보 발행</div>
|
||||
<div class="meta-row"><span>논리포트 <b>8094</b></span><span>유형 <b>Headless JVM</b></span><span>코드네임 <b>Persistent Repository · Aggregation</b></span></div>
|
||||
<p>
|
||||
정산 결과를 소비해 <b>완결 규율</b>을 적용합니다. 과반(정족수) 센터가 동일 결과를 확정하면
|
||||
최종 상태 <b>ACCC(입금처리완료)</b>로 확정하고 조회 전용 사본을 남깁니다. 완결이 커밋되면
|
||||
결과통보(<code>rtgs.notify</code>)를 발행해 Chanel→Gucci를 통한 기관 통보로 이어집니다.
|
||||
</p>
|
||||
<table class="metrics">
|
||||
<tr><th>카드 지표</th><th>의미</th></tr>
|
||||
<tr><td>완결(ACCC)</td><td>최종 완결된 거래 건수</td></tr>
|
||||
<tr><td>반려(RJCT)</td><td>검증 실패·잔액 부족 등으로 반려된 건수</td></tr>
|
||||
</table>
|
||||
<div>바로가기 <span class="btn-chip">완결/조회사본</span> — 관리자 콘솔의 완결 현황</div>
|
||||
</div>
|
||||
|
||||
<!-- Louis Vuitton -->
|
||||
<div class="svc" style="--bar: var(--lv)">
|
||||
<h3>🛠️ Louis Vuitton <span class="tag">관리자 · 운영</span></h3>
|
||||
<div class="sub">시스템관리 — DB초기화 · 코드 · 사용자권한 · 대사 대시보드</div>
|
||||
<div class="meta-row"><span>논리포트 <b>8099</b></span><span>유형 <b>Web(Tomcat)</b></span><span>코드네임 <b>Leading Operations Unified Info Systems</b></span></div>
|
||||
<p>
|
||||
시스템 <b>관리·운영</b> 서비스입니다. DB 초기화, 상태코드·기관·사용자 권한 관리, 헬스(하트비트) 집계,
|
||||
원장=저널=조회사본 <b>대사(對査) 대시보드</b>를 제공합니다. 스키마는 Flyway로 형상 관리하며 기동 시 자동 적용합니다.
|
||||
(관리자 콘솔은 ADMIN JWT 필수, 테스트 계정 <code>a / 1</code>)
|
||||
</p>
|
||||
<table class="metrics">
|
||||
<tr><th>카드 지표</th><th>의미</th></tr>
|
||||
<tr><td>총 거래</td><td>원장에 기록된 전체 거래 건수</td></tr>
|
||||
<tr><td>최대순번</td><td>부여된 전역순번의 최댓값(<code>MAX(global_seq)</code>)</td></tr>
|
||||
</table>
|
||||
<div>바로가기 <span class="btn-chip">관리자 콘솔</span> <span class="btn-chip">로그(Kibana)</span></div>
|
||||
</div>
|
||||
|
||||
<h2 id="status">5. 거래 상태(TxSts) 전이</h2>
|
||||
<p>한 건의 거래는 아래 상태를 따라 진행됩니다.</p>
|
||||
<table>
|
||||
<tr><th>상태</th><th>한글</th><th>담당</th><th>설명</th></tr>
|
||||
<tr><td><code>RCVD</code></td><td>접수</td><td>Chanel</td><td>전문 접수·검증 통과</td></tr>
|
||||
<tr><td><code>ACTC</code></td><td>순번</td><td>Sequencer</td><td>전역순번 부여</td></tr>
|
||||
<tr><td><code>PDNG</code></td><td>대기</td><td>Dior</td><td>원장에 대기 기록(접수 동기화)</td></tr>
|
||||
<tr><td><code>ACSP</code></td><td>정산반영</td><td>Hermes</td><td>당좌 차/대변 반영</td></tr>
|
||||
<tr><td><code>ACCC</code></td><td>입금처리완료</td><td>Prada</td><td>과반 확정 · 최종 완결</td></tr>
|
||||
<tr><td><code>RJCT</code></td><td>반려</td><td>Chanel/Hermes</td><td>XSD·업무규칙 실패 또는 잔액부족·미등록기관</td></tr>
|
||||
</table>
|
||||
<div class="note">
|
||||
정상 흐름: <code>RCVD → ACTC → PDNG → ACSP → ACCC</code>.
|
||||
검증·잔액 문제 시 <code>RJCT</code>로 종료됩니다.
|
||||
</div>
|
||||
|
||||
<h2 id="ports">6. 포트 일람</h2>
|
||||
<table>
|
||||
<tr><th>서비스</th><th>포트</th><th>유형</th></tr>
|
||||
<tr><td>🔢 Sequencer</td><td><code>8090</code></td><td>Headless JVM (논리)</td></tr>
|
||||
<tr><td>📥 Chanel</td><td><code>8091</code></td><td>Web (Tomcat)</td></tr>
|
||||
<tr><td>🔄 Dior</td><td><code>8092</code></td><td>Headless JVM (논리)</td></tr>
|
||||
<tr><td>💰 Hermes</td><td><code>8093</code></td><td>Headless JVM (논리)</td></tr>
|
||||
<tr><td>✅ Prada</td><td><code>8094</code></td><td>Headless JVM (논리)</td></tr>
|
||||
<tr><td>🛡️ Gucci</td><td><code>8095</code></td><td>Web (Tomcat)</td></tr>
|
||||
<tr><td>🛠️ Louis Vuitton</td><td><code>8099</code></td><td>Web (Tomcat)</td></tr>
|
||||
<tr><td>운영/관리 콘솔 (Frontend)</td><td><code>5174</code></td><td>React + Vite</td></tr>
|
||||
<tr><td>PostgreSQL (원장)</td><td><code>5433</code></td><td>RDBMS</td></tr>
|
||||
<tr><td>Kafka (입구/저널/결과/통보)</td><td><code>9092</code></td><td>KRaft</td></tr>
|
||||
<tr><td>Elasticsearch / Logstash / Kibana</td><td><code>9200 / 5000 / 5601</code></td><td>ELK 로그</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="glossary">7. 용어</h2>
|
||||
<table>
|
||||
<tr><th>약어</th><th>의미</th></tr>
|
||||
<tr><td>RTGS</td><td>Real-Time Gross Settlement — 실시간총액결제</td></tr>
|
||||
<tr><td>A-A-A</td><td>Active-Active-Active — 3센터 동시 가동</td></tr>
|
||||
<tr><td>BMI</td><td>Business Message Identifier — 거래식별자 22자리(영업일8+기관4+일련10)</td></tr>
|
||||
<tr><td>저널</td><td>전역순번이 부여된 처리 명단(Kafka 단일 파티션)</td></tr>
|
||||
<tr><td>정족수</td><td>Quorum(N/2+1). 3센터=2</td></tr>
|
||||
<tr><td>선저널</td><td>Write-ahead journal — 잔액연산 전 순번 확정(무손실 승계)</td></tr>
|
||||
<tr><td>pacs.008 / pacs.002</td><td>ISO 20022 결제의뢰 / 결과통보 전문</td></tr>
|
||||
<tr><td>TPS</td><td>Transactions Per Second — 초당 처리 건수</td></tr>
|
||||
</table>
|
||||
|
||||
<footer>
|
||||
RTGS 프로토타입 · 운영/관리 콘솔 서비스 매뉴얼 · 작성 루비(AI) · 검토 양희정 팀장(한국은행 RTGS시스템팀)<br>
|
||||
상세 설계는 <code>docs/RTGS 아키텍처 설계서.md</code> 및 워크플로우 문서를 참조하세요.
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
65
frontend/src/AdminLogin.tsx
Normal file
65
frontend/src/AdminLogin.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth, login, changePassword, getUser } from './auth'
|
||||
import { box } from './util'
|
||||
|
||||
/**
|
||||
* 관리자 로그인 게이트. 인증 전이면 로그인 폼, 초기암호변경 필요 시 변경 폼,
|
||||
* 그 외에는 children(보호 대상 화면)을 렌더한다. 관리자 패널·포탈이 공유.
|
||||
*/
|
||||
export default function LoginGate({ children }: { children: React.ReactNode }) {
|
||||
const { token, logout } = useAuth()
|
||||
const [form, setForm] = useState({ username: '', secret: '' })
|
||||
const [mustChange, setMustChange] = useState(false)
|
||||
const [newSecret, setNewSecret] = useState('')
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
|
||||
const doLogin = async () => {
|
||||
setErr(null)
|
||||
const r = await login(form.username, form.secret)
|
||||
if (!r.ok) { setErr(r.error!); return }
|
||||
setMustChange(!!r.mustChange) // 성공 시 token이 전역 세팅 → useAuth 재렌더
|
||||
}
|
||||
const doChange = async () => {
|
||||
setErr(null)
|
||||
if (newSecret.length < 1) { setErr('새 비밀번호를 입력하세요'); return }
|
||||
const r = await changePassword(getUser(), form.secret, newSecret)
|
||||
if (!r.ok) { setErr(r.error!); return }
|
||||
setMsg('비밀번호가 변경되었습니다.'); setMustChange(false)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#f7fbff' }}>
|
||||
<h2 style={{ marginTop: 0 }}>🔐 관리자 로그인</h2>
|
||||
<p style={{ color: '#666', fontSize: 13 }}>시스템관리(LouisVuitton)·포탈 상태 조회는 <b>ADMIN</b> 로그인이 필요합니다.</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<input placeholder="아이디" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} style={{ padding: 8 }} />
|
||||
<input placeholder="비밀번호" type="password" value={form.secret}
|
||||
onChange={(e) => setForm({ ...form, secret: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') doLogin() }} style={{ padding: 8 }} />
|
||||
<button onClick={doLogin} style={{ padding: '10px', background: '#2d6cdf', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}>로그인</button>
|
||||
</div>
|
||||
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
if (mustChange) {
|
||||
return (
|
||||
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#fff8f0' }}>
|
||||
<h2 style={{ marginTop: 0 }}>🔑 초기 비밀번호 변경</h2>
|
||||
<p style={{ color: '#666', fontSize: 13 }}>본격 사용 전 초기 비밀번호를 변경해야 합니다.</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<input placeholder="새 비밀번호" type="password" value={newSecret}
|
||||
onChange={(e) => setNewSecret(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') doChange() }} style={{ padding: 8 }} />
|
||||
<button onClick={doChange} style={{ padding: '10px', background: '#c0392b', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}>변경</button>
|
||||
<button onClick={logout} style={{ fontSize: 12 }}>로그아웃</button>
|
||||
</div>
|
||||
{msg && <p style={{ color: '#1a7f37', marginTop: 12 }}>{msg}</p>}
|
||||
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,52 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const won = (n: number) => n.toLocaleString('ko-KR') + '원'
|
||||
function fmtTs(ms?: unknown): string {
|
||||
if (typeof ms !== 'number' || !ms) return '-'
|
||||
const d = new Date(ms); const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
||||
}
|
||||
const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 }
|
||||
const cell: React.CSSProperties = { padding: '5px 9px', borderBottom: '1px solid #eee', fontSize: 13 }
|
||||
const th: React.CSSProperties = { ...cell, borderBottom: '2px solid #ccc', textAlign: 'left', background: '#fafafa' }
|
||||
|
||||
type Row = Record<string, any>
|
||||
|
||||
/** 정식 pacs.008.001.08 전문 생성(테스트 실행용). */
|
||||
function buildPacs008(bmi: string, from: string, to: string, amt: number): string {
|
||||
const now = new Date().toISOString().replace(/\.\d+Z$/, '')
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">` +
|
||||
`<FIToFICstmrCdtTrf><GrpHdr><MsgId>${bmi}</MsgId><CreDtTm>${now}</CreDtTm><NbOfTxs>1</NbOfTxs>` +
|
||||
`<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr><CdtTrfTxInf><PmtId><EndToEndId>${bmi}</EndToEndId></PmtId>` +
|
||||
`<IntrBkSttlmAmt Ccy="KRW">${amt}</IntrBkSttlmAmt><ChrgBr>SLEV</ChrgBr>` +
|
||||
`<Dbtr><Nm>BANK-${from}</Nm></Dbtr><DbtrAcct><Id><Othr><Id>ACC-${from}</Id></Othr></Id></DbtrAcct>` +
|
||||
`<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>${from}</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>` +
|
||||
`<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>${to}</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>` +
|
||||
`<Cdtr><Nm>BANK-${to}</Nm></Cdtr><CdtrAcct><Id><Othr><Id>ACC-${to}</Id></Othr></Id></CdtrAcct>` +
|
||||
`</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>`
|
||||
}
|
||||
|
||||
function MiniTable({ title, cols, rows, timeCols = [] }: { title: string; cols: [string, string][]; rows: Row[]; timeCols?: string[] }) {
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{title} <span style={{ color: '#888', fontSize: 12 }}>({rows.length})</span></div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead><tr>{cols.map(([, ko]) => <th key={ko} style={th}>{ko}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>{cols.map(([k]) => <td key={k} style={{ ...cell, fontFamily: /code|seq|bmi|balance|amount/.test(k) ? 'monospace' : undefined }}>
|
||||
{timeCols.includes(k) ? fmtTs(r[k]) : String(r[k] ?? '-')}
|
||||
</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useAuth, jget, jsend } from './auth'
|
||||
import LoginGate from './AdminLogin'
|
||||
import { won, box, cell, th, MiniTable, buildPacs008, Row } from './util'
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { token, user, logout } = useAuth()
|
||||
const [summary, setSummary] = useState<any>(null)
|
||||
const [transfers, setTransfers] = useState<Row[]>([])
|
||||
const [journal, setJournal] = useState<Row[]>([])
|
||||
@@ -54,92 +12,74 @@ export default function AdminPanel() {
|
||||
const [insts, setInsts] = useState<Row[]>([])
|
||||
const [codes, setCodes] = useState<Row[]>([])
|
||||
const [users, setUsers] = useState<Row[]>([])
|
||||
const [health, setHealth] = useState<Row[]>([])
|
||||
const [services, setServices] = useState<any>(null)
|
||||
const [svcLog, setSvcLog] = useState<{ id: string; lines: string[] } | null>(null)
|
||||
const [busySvc, setBusySvc] = useState<string | null>(null)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
// 인증(A1): Gucci 로그인으로 발급받은 ADMIN JWT
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem('rtgs_admin_token'))
|
||||
const [loginUser, setLoginUser] = useState<string>(() => localStorage.getItem('rtgs_admin_user') || '')
|
||||
const [loginForm, setLoginForm] = useState({ username: '', secret: '' })
|
||||
const [mustChange, setMustChange] = useState(false)
|
||||
const [newSecret, setNewSecret] = useState('')
|
||||
|
||||
// 폼 상태
|
||||
const [inst, setInst] = useState({ code: '', name: '', balance: 1000000000 })
|
||||
const [user, setUser] = useState({ username: '', displayName: '', role: 'ORG_S', orgCode: '' })
|
||||
const [user2, setUser2] = useState({ username: '', displayName: '', role: 'ORG_S', orgCode: '' })
|
||||
|
||||
// 테스트 실행(B6)
|
||||
const [test, setTest] = useState({ bmiStart: '2026071099000000000001', count: 50 })
|
||||
const [testRun, setTestRun] = useState<{ running: boolean; done: number; sent: number; rcvd: number; rjct: number; err: number } | null>(null)
|
||||
|
||||
const authHeaders = (json = false): Record<string, string> => {
|
||||
const h: Record<string, string> = {}
|
||||
if (json) h['Content-Type'] = 'application/json'
|
||||
if (token) h['Authorization'] = `Bearer ${token}`
|
||||
return h
|
||||
}
|
||||
const logout = () => { localStorage.removeItem('rtgs_admin_token'); localStorage.removeItem('rtgs_admin_user'); setToken(null); setMustChange(false) }
|
||||
const check401 = (r: Response) => { if (r.status === 401) { logout(); throw new Error('세션 만료 또는 권한 없음 — 다시 로그인하세요') } }
|
||||
|
||||
const j = async (u: string) => { const r = await fetch(u, { headers: authHeaders() }); check401(r); return r.json() }
|
||||
const post = async (url: string, body?: any, method = 'POST') => {
|
||||
const r = await fetch(url, { method, headers: authHeaders(!!body), body: body ? JSON.stringify(body) : undefined })
|
||||
check401(r); return r.json()
|
||||
}
|
||||
|
||||
const doLogin = async () => {
|
||||
setErr(null)
|
||||
try {
|
||||
const resp = await fetch('/gucci/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(loginForm) })
|
||||
if (!resp.ok) { setErr('로그인 실패 — 아이디/비밀번호를 확인하세요'); return }
|
||||
const r = await resp.json()
|
||||
if (r.role !== 'ADMIN') { setErr('관리자(ADMIN) 권한이 필요합니다'); return }
|
||||
localStorage.setItem('rtgs_admin_token', r.token); localStorage.setItem('rtgs_admin_user', loginForm.username)
|
||||
setLoginUser(loginForm.username); setToken(r.token); setMustChange(!!r.mustChangePassword)
|
||||
} catch (e: any) { setErr('로그인 실패: ' + e.message) }
|
||||
}
|
||||
const doChangePassword = async () => {
|
||||
setErr(null)
|
||||
if (newSecret.length < 1) { setErr('새 비밀번호를 입력하세요'); return }
|
||||
try {
|
||||
const resp = await fetch('/gucci/auth/change-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: loginUser, oldSecret: loginForm.secret, newSecret }),
|
||||
})
|
||||
if (!resp.ok) { setErr('비밀번호 변경 실패'); return }
|
||||
setMsg('비밀번호가 변경되었습니다.'); setMustChange(false); setNewSecret('')
|
||||
} catch (e: any) { setErr('변경 실패: ' + e.message) }
|
||||
}
|
||||
|
||||
const loadAll = async () => {
|
||||
setErr(null)
|
||||
try {
|
||||
const [s, t, jn, v, ins, cd, us, hl] = await Promise.all([
|
||||
j('/admin/summary'), j('/admin/ledger/transfers?limit=15'), j('/admin/ledger/journal?limit=15'),
|
||||
j('/admin/ledger/views?limit=15'), j('/admin/institutions'), j('/admin/status-codes'), j('/admin/users'), j('/admin/health'),
|
||||
const [s, t, jn, v, ins, cd, us] = await Promise.all([
|
||||
jget('/admin/summary'), jget('/admin/ledger/transfers?limit=15'), jget('/admin/ledger/journal?limit=15'),
|
||||
jget('/admin/ledger/views?limit=15'), jget('/admin/institutions'), jget('/admin/status-codes'), jget('/admin/users'),
|
||||
])
|
||||
setSummary(s); setTransfers(t); setJournal(jn); setViews(v); setInsts(ins); setCodes(cd); setUsers(us); setHealth(hl)
|
||||
setSummary(s); setTransfers(t); setJournal(jn); setViews(v); setInsts(ins); setCodes(cd); setUsers(us)
|
||||
} catch (e: any) { setErr('관리자 데이터 로드 실패: ' + e.message) }
|
||||
}
|
||||
useEffect(() => { if (token && !mustChange) loadAll() }, [token, mustChange])
|
||||
const loadServices = async () => { try { setServices(await jget('/admin/services')) } catch { /* 폴링 실패 무시 */ } }
|
||||
useEffect(() => { if (token) loadAll() }, [token])
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
loadServices()
|
||||
const t = setInterval(loadServices, 4000)
|
||||
return () => clearInterval(t)
|
||||
}, [token])
|
||||
|
||||
// 서비스 제어(기동/중지/재기동)
|
||||
const svcAction = async (id: string, action: 'start' | 'stop' | 'restart', name: string) => {
|
||||
if ((action === 'stop' || action === 'restart') && !confirm(`${name} ${action === 'stop' ? '중지' : '재기동'}할까요?`)) return
|
||||
setBusySvc(id + action); setMsg(null); setErr(null)
|
||||
try {
|
||||
const r = await jsend(`/admin/services/${id}/${action}`)
|
||||
if (r.ok === false) setErr(r.message || '제어 실패'); else setMsg(r.message || '완료')
|
||||
} catch (e: any) { setErr('제어 실패: ' + e.message) }
|
||||
finally { setBusySvc(null); setTimeout(loadServices, 900) }
|
||||
}
|
||||
const showLog = async (id: string) => {
|
||||
try { const r = await jget(`/admin/services/${id}/log?lines=150`); setSvcLog({ id, lines: r.lines || [] }) }
|
||||
catch (e: any) { setErr('로그 조회 실패: ' + e.message) }
|
||||
}
|
||||
const ctlBtn = (bg: string, disabled: boolean): React.CSSProperties => ({
|
||||
padding: '4px 10px', background: disabled ? '#ccc' : bg, color: '#fff', border: 0, borderRadius: 5,
|
||||
cursor: disabled ? 'default' : 'pointer', fontSize: 12,
|
||||
})
|
||||
|
||||
const doReset = async () => {
|
||||
if (!confirm('정말 초기화할까요?\n거래/저널/조회사본/원전문을 모두 비우고 계좌 잔액을 각 10억으로 되돌립니다.')) return
|
||||
setMsg(null); setErr(null)
|
||||
try { const r = await post('/admin/reset'); setMsg(`초기화 완료: 계좌 ${r.accountsReset}개 리셋, 원장 비움`); await loadAll() }
|
||||
try { const r = await jsend('/admin/reset'); setMsg(`초기화 완료: 계좌 ${r.accountsReset}개 리셋, 원장 비움`); await loadAll() }
|
||||
catch (e: any) { setErr('초기화 실패: ' + e.message) }
|
||||
}
|
||||
const saveInst = async () => {
|
||||
if (!/^[0-9]{4}$/.test(inst.code)) { setErr('기관코드는 숫자 4자리'); return }
|
||||
try { await post('/admin/institutions', inst); setMsg(`기관 저장: ${inst.code}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
|
||||
try { await jsend('/admin/institutions', inst); setMsg(`기관 저장: ${inst.code}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
|
||||
}
|
||||
const delInst = async (code: string) => { if (confirm(`기관 ${code} 삭제?`)) { await post('/admin/institutions/' + code, undefined, 'DELETE'); await loadAll() } }
|
||||
const delInst = async (code: string) => { if (confirm(`기관 ${code} 삭제?`)) { await jsend('/admin/institutions/' + code, undefined, 'DELETE'); await loadAll() } }
|
||||
const saveUser = async () => {
|
||||
if (!user.username) { setErr('사용자ID 필수'); return }
|
||||
try { await post('/admin/users', { ...user, orgCode: user.orgCode || null }); setMsg(`사용자 저장: ${user.username}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
|
||||
if (!user2.username) { setErr('사용자ID 필수'); return }
|
||||
try { await jsend('/admin/users', { ...user2, orgCode: user2.orgCode || null }); setMsg(`사용자 저장: ${user2.username}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
|
||||
}
|
||||
const delUser = async (u: string) => { if (confirm(`사용자 ${u} 삭제?`)) { await post('/admin/users/' + u, undefined, 'DELETE'); await loadAll() } }
|
||||
const delUser = async (u: string) => { if (confirm(`사용자 ${u} 삭제?`)) { await jsend('/admin/users/' + u, undefined, 'DELETE'); await loadAll() } }
|
||||
|
||||
// B6: 테스트 이체 대량 생성(송/수신 랜덤, 금액 1,000~1,000,000 랜덤, BMI 시작번호부터 순번 증가)
|
||||
const BANKS_FALLBACK = ['1001', '1002', '1003', '1004', '1005', '1006', '1007', '1008', '1009', '1010']
|
||||
@@ -177,56 +117,62 @@ export default function AdminPanel() {
|
||||
|
||||
const totalOk = summary && summary.totalBalance === (summary.accounts * 1000000000)
|
||||
|
||||
// --- A1: 로그인 게이트 ---
|
||||
if (!token) {
|
||||
return (
|
||||
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#f7fbff' }}>
|
||||
<h2 style={{ marginTop: 0 }}>🔐 관리자 로그인</h2>
|
||||
<p style={{ color: '#666', fontSize: 13 }}>시스템관리(LouisVuitton) 콘솔은 <b>ADMIN</b> 로그인이 필요합니다.<br />테스트 계정: <code>a / 1</code></p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<input placeholder="아이디" value={loginForm.username} onChange={(e) => setLoginForm({ ...loginForm, username: e.target.value })} style={{ padding: 8 }} />
|
||||
<input placeholder="비밀번호" type="password" value={loginForm.secret}
|
||||
onChange={(e) => setLoginForm({ ...loginForm, secret: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') doLogin() }} style={{ padding: 8 }} />
|
||||
<button onClick={doLogin} style={{ padding: '10px', background: '#2d6cdf', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}>로그인</button>
|
||||
</div>
|
||||
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
if (mustChange) {
|
||||
return (
|
||||
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#fff8f0' }}>
|
||||
<h2 style={{ marginTop: 0 }}>🔑 초기 비밀번호 변경</h2>
|
||||
<p style={{ color: '#666', fontSize: 13 }}>본격 사용 전 초기 비밀번호를 변경해야 합니다.</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<input placeholder="새 비밀번호" type="password" value={newSecret}
|
||||
onChange={(e) => setNewSecret(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') doChangePassword() }} style={{ padding: 8 }} />
|
||||
<button onClick={doChangePassword} style={{ padding: '10px', background: '#c0392b', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}>변경</button>
|
||||
<button onClick={logout} style={{ fontSize: 12 }}>로그아웃</button>
|
||||
</div>
|
||||
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LoginGate>
|
||||
<div>
|
||||
<div style={{ textAlign: 'right', color: '#666', fontSize: 13, marginTop: 8 }}>
|
||||
👤 <b>{loginUser}</b> (ADMIN) <button onClick={logout} style={{ fontSize: 12, marginLeft: 8 }}>로그아웃</button>
|
||||
👤 <b>{user}</b> (ADMIN) <button onClick={logout} style={{ fontSize: 12, marginLeft: 8 }}>로그아웃</button>
|
||||
</div>
|
||||
{/* 서비스 상태(B5 하트비트) */}
|
||||
{/* 서비스 제어 (기동/중지/재기동 · 하트비트 상태 · 4초 갱신) */}
|
||||
<section style={{ ...box, background: '#f7fff7' }}>
|
||||
<h2 style={{ marginTop: 0 }}>🩺 서비스 상태 <span style={{ fontSize: 12, color: '#888' }}>(하트비트 기반 · 헤드리스 포함)</span></h2>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{health.length === 0 && <span style={{ color: '#888' }}>하트비트 수집 대기…</span>}
|
||||
{health.map((h) => (
|
||||
<span key={String(h.service) + h.center} style={{ padding: '4px 10px', borderRadius: 6, fontSize: 13, background: h.status === 'UP' ? '#e6f4ea' : '#fde8e8', border: '1px solid ' + (h.status === 'UP' ? '#8bd3a0' : '#f0a0a0') }}>
|
||||
{h.status === 'UP' ? '🟢' : '🔴'} <b>{String(h.service)}</b> <span style={{ color: '#666' }}>({String(h.center)}·pid {String(h.pid)}·{Math.round(Number(h.ageMs) / 1000)}s)</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 style={{ marginTop: 0 }}>🖥️ 서비스 제어 <span style={{ fontSize: 12, color: '#888' }}>(기동·중지·재기동 · 하트비트 기반 · 4초 자동 갱신)</span></h2>
|
||||
{services?.controlEnabled === false && <p style={{ color: '#b58900', margin: '4px 0' }}>⚠ 제어 비활성화(rtgs.control.enabled=false) — 상태 조회만 가능</p>}
|
||||
{!services && <span style={{ color: '#888' }}>상태 수집 대기…</span>}
|
||||
{services && (
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead><tr>
|
||||
<th style={th}>상태</th><th style={th}>서비스</th><th style={th}>포트</th><th style={th}>PID</th><th style={th}>제어</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{(services.services ?? []).map((s: any) => (
|
||||
<tr key={s.id}>
|
||||
<td style={cell}>
|
||||
{s.status === 'UP' ? '🟢 UP' : '🔴 DOWN'}
|
||||
{s.status === 'UP' && s.ageMs != null && <span style={{ color: '#888', fontSize: 11 }}> · {Math.round(s.ageMs / 1000)}s</span>}
|
||||
</td>
|
||||
<td style={{ ...cell, fontWeight: 600 }}>{s.name}</td>
|
||||
<td style={{ ...cell, fontFamily: 'monospace' }}>:{s.port}</td>
|
||||
<td style={{ ...cell, fontFamily: 'monospace', color: '#888' }}>{s.pid ?? '-'}</td>
|
||||
<td style={cell}>
|
||||
{s.controllable ? (
|
||||
<span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button disabled={s.status === 'UP' || busySvc !== null} onClick={() => svcAction(s.id, 'start', s.name)} style={ctlBtn('#1a7f37', s.status === 'UP' || busySvc !== null)}>기동</button>
|
||||
<button disabled={s.status !== 'UP' || busySvc !== null} onClick={() => svcAction(s.id, 'stop', s.name)} style={ctlBtn('#c0392b', s.status !== 'UP' || busySvc !== null)}>중지</button>
|
||||
<button disabled={s.status !== 'UP' || busySvc !== null} onClick={() => svcAction(s.id, 'restart', s.name)} style={ctlBtn('#2d6cdf', s.status !== 'UP' || busySvc !== null)}>재기동</button>
|
||||
<button disabled={busySvc !== null} onClick={() => showLog(s.id)} style={ctlBtn('#666', busySvc !== null)}>로그</button>
|
||||
</span>
|
||||
) : <span style={{ color: '#999', fontSize: 12 }}>선행 필수(스크립트로 기동)</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(services.infra ?? []).map((f: any) => (
|
||||
<tr key={f.id} style={{ background: '#fafafa' }}>
|
||||
<td style={cell}>{f.reachable ? '🟢 UP' : '🔴 DOWN'}</td>
|
||||
<td style={{ ...cell, fontWeight: 600 }}>{f.name} <span style={{ fontSize: 11, color: '#999' }}>(인프라)</span></td>
|
||||
<td style={{ ...cell, fontFamily: 'monospace' }}>:{f.port}</td>
|
||||
<td style={cell}>-</td>
|
||||
<td style={{ ...cell, color: '#999', fontSize: 12 }}>선행 필수(스크립트로 기동)</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{svcLog && (
|
||||
<details open style={{ marginTop: 10 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>📜 {svcLog.id}.log (최근 {svcLog.lines.length}줄) <button onClick={() => setSvcLog(null)} style={{ fontSize: 11, marginLeft: 8 }}>닫기</button></summary>
|
||||
<pre style={{ background: '#0d1117', color: '#c9d1d9', padding: 12, marginTop: 8, maxHeight: 300, overflow: 'auto', fontSize: 12 }}>{svcLog.lines.join('\n') || '(로그 없음)'}</pre>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 대사 대시보드 */}
|
||||
@@ -283,12 +229,12 @@ export default function AdminPanel() {
|
||||
<section style={box}>
|
||||
<h2 style={{ marginTop: 0 }}>👤 사용자 권한 관리 <span style={{ fontSize: 12, color: '#888' }}>(관리 마스터 · ADMIN 로그인 적용됨)</span></h2>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
|
||||
<label>사용자ID<br /><input value={user.username} onChange={(e) => setUser({ ...user, username: e.target.value })} style={{ padding: 6, width: 120 }} /></label>
|
||||
<label>사용자명<br /><input value={user.displayName} onChange={(e) => setUser({ ...user, displayName: e.target.value })} style={{ padding: 6, width: 140 }} /></label>
|
||||
<label>역할<br /><select value={user.role} onChange={(e) => setUser({ ...user, role: e.target.value })} style={{ padding: 6 }}>
|
||||
<label>사용자ID<br /><input value={user2.username} onChange={(e) => setUser2({ ...user2, username: e.target.value })} style={{ padding: 6, width: 120 }} /></label>
|
||||
<label>사용자명<br /><input value={user2.displayName} onChange={(e) => setUser2({ ...user2, displayName: e.target.value })} style={{ padding: 6, width: 140 }} /></label>
|
||||
<label>역할<br /><select value={user2.role} onChange={(e) => setUser2({ ...user2, role: e.target.value })} style={{ padding: 6 }}>
|
||||
<option value="ADMIN">ADMIN(관리자)</option><option value="ORG_S">ORG_S(송신기관)</option><option value="ORG_R">ORG_R(수신기관)</option>
|
||||
</select></label>
|
||||
<label>매핑기관<br /><input value={user.orgCode} onChange={(e) => setUser({ ...user, orgCode: e.target.value })} placeholder="1001" style={{ padding: 6, width: 90, fontFamily: 'monospace' }} /></label>
|
||||
<label>매핑기관<br /><input value={user2.orgCode} onChange={(e) => setUser2({ ...user2, orgCode: e.target.value })} placeholder="1001" style={{ padding: 6, width: 90, fontFamily: 'monospace' }} /></label>
|
||||
<button onClick={saveUser} style={{ padding: '8px 16px' }}>추가/수정</button>
|
||||
</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
@@ -322,5 +268,6 @@ export default function AdminPanel() {
|
||||
{msg && <p style={{ color: '#1a7f37', marginTop: 12 }}>{msg}</p>}
|
||||
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
|
||||
</div>
|
||||
</LoginGate>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import AdminPanel from './AdminPanel'
|
||||
import Portal from './Portal'
|
||||
|
||||
type Account = { code: string; name: string; balance: number }
|
||||
type Inquiry = Record<string, unknown>
|
||||
@@ -26,6 +27,16 @@ const STATUS_KO: Record<string, string> = {
|
||||
RCVD: '접수', ACTC: '승인', PDNG: '대기', ACSP: '예약', ACCC: '입금처리완료', RJCT: '반려', IN_FLIGHT: '처리중',
|
||||
}
|
||||
const TIME_FIELDS = new Set(['updatedAt', 'createdAt'])
|
||||
// 서비스별 처리시각 타임라인(한 거래가 거치는 5개 서비스). inquiry 응답의 epoch(ms) 필드와 매핑.
|
||||
const TIMELINE_STAGES: { key: string; label: string; svc: string; emoji: string }[] = [
|
||||
{ key: 'rcvdAt', label: '접수', svc: 'Chanel', emoji: '📥' },
|
||||
{ key: 'actcAt', label: '순번', svc: 'Sequencer', emoji: '🔢' },
|
||||
{ key: 'pdngAt', label: '기록', svc: 'Dior', emoji: '🔄' },
|
||||
{ key: 'acspAt', label: '정산', svc: 'Hermes', emoji: '💰' },
|
||||
{ key: 'acccAt', label: '완결', svc: 'Prada', emoji: '✅' },
|
||||
]
|
||||
// 타임라인으로 별도 표기하므로 상세표에서는 숨김.
|
||||
const TIMELINE_KEYS = new Set(TIMELINE_STAGES.map((s) => s.key))
|
||||
const statusColor = (s?: string) =>
|
||||
s === 'ACCC' ? '#1a7f37' : s === 'RJCT' ? '#c00' : s ? '#8a6d00' : '#555'
|
||||
|
||||
@@ -51,8 +62,10 @@ export default function App() {
|
||||
const [bmi, setBmi] = useState('')
|
||||
const [inquiry, setInquiry] = useState<Inquiry | null>(null)
|
||||
const [rawXml, setRawXml] = useState<string | null>(null)
|
||||
const [notifs, setNotifs] = useState<Record<string, unknown>[]>([])
|
||||
const [msgTab, setMsgTab] = useState<'008' | '002'>('008')
|
||||
|
||||
const [view, setView] = useState<'ops' | 'admin'>('ops')
|
||||
const [view, setView] = useState<'portal' | 'ops' | 'admin'>('portal')
|
||||
|
||||
const loadAccounts = async () => {
|
||||
setErr(null)
|
||||
@@ -109,22 +122,25 @@ export default function App() {
|
||||
}
|
||||
|
||||
const doInquiry = async () => {
|
||||
setErr(null); setInquiry(null); setRawXml(null)
|
||||
setErr(null); setInquiry(null); setRawXml(null); setNotifs([]); setMsgTab('008')
|
||||
const key = bmi.trim()
|
||||
if (!key) return
|
||||
try {
|
||||
const [inq, raw] = await Promise.all([
|
||||
const [inq, raw, nts] = await Promise.all([
|
||||
fetch('/inquiry/' + encodeURIComponent(key)).then((r) => r.json()),
|
||||
fetch('/rawmessage/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => null),
|
||||
fetch('/notifications/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => []),
|
||||
])
|
||||
setInquiry(inq)
|
||||
setRawXml(raw?.found ? raw.rawXml : null)
|
||||
setNotifs(Array.isArray(nts) ? nts : [])
|
||||
} catch (e: any) {
|
||||
setErr('거래 조회 실패: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadAccounts(); loadLabels() }, [])
|
||||
// 계좌·라벨은 운영 콘솔에서만 쓰이므로 해당 탭 진입 시 로드(로그인/포탈 화면에서 불필요한 Chanel 호출·에러 방지)
|
||||
useEffect(() => { if (view === 'ops') { loadAccounts(); loadLabels() } }, [view])
|
||||
|
||||
const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 }
|
||||
const cell: React.CSSProperties = { padding: '6px 10px', borderBottom: '1px solid #eee' }
|
||||
@@ -140,16 +156,25 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 900, margin: '2rem auto', padding: '0 1rem' }}>
|
||||
<h1>RTGS 콘솔 <span style={{ fontSize: 14, color: '#888' }}>(DC1 · :5174)</span></h1>
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif', margin: '1.5rem', padding: '0 0.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<h1 style={{ margin: 0 }}>RTGS 콘솔 <span style={{ fontSize: 14, color: '#888' }}>(DC1 · :5174)</span></h1>
|
||||
<a href="/manual.html" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', border: '1px solid #ccc', borderRadius: 6, background: '#fff', color: '#333', textDecoration: 'none', fontSize: 14, fontWeight: 600 }}>
|
||||
📖 서비스 매뉴얼
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, margin: '12px 0' }}>
|
||||
<button onClick={() => setView('portal')}
|
||||
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'portal' ? '#0b63c4' : '#fff', color: view === 'portal' ? '#fff' : '#333' }}>🏛 포탈</button>
|
||||
<button onClick={() => setView('ops')}
|
||||
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'ops' ? '#0b63c4' : '#fff', color: view === 'ops' ? '#fff' : '#333' }}>운영 콘솔</button>
|
||||
<button onClick={() => setView('admin')}
|
||||
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'admin' ? '#0b63c4' : '#fff', color: view === 'admin' ? '#fff' : '#333' }}>🛠 관리자 (Louis Vuitton)</button>
|
||||
</div>
|
||||
|
||||
{view === 'portal' && <Portal onNavigate={setView} />}
|
||||
{view === 'admin' && <AdminPanel />}
|
||||
|
||||
{view === 'ops' && (<>
|
||||
@@ -208,7 +233,7 @@ export default function App() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(inquiry).filter(([k]) => k !== 'note').map(([k, v]) => (
|
||||
{Object.entries(inquiry).filter(([k]) => k !== 'note' && !TIMELINE_KEYS.has(k)).map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td style={{ ...cell, fontWeight: 600 }}>{labels[k] ?? k}</td>
|
||||
<td style={{ ...cell, color: '#888', fontFamily: 'monospace' }}>{k}</td>
|
||||
@@ -219,13 +244,69 @@ export default function App() {
|
||||
</table>
|
||||
)}
|
||||
|
||||
{rawXml && (
|
||||
<details style={{ marginTop: 12 }} open>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>📄 원문 ISO20022 pacs.008 전문</summary>
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, marginTop: 8, overflowX: 'auto', fontSize: 13 }}>{prettyXml(rawXml)}</pre>
|
||||
</details>
|
||||
{/* 서비스별 처리시각 타임라인(접수→순번→기록→정산→완결) */}
|
||||
{inquiry && !(inquiry as any).note && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🕘 서비스별 처리시각</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
|
||||
<th style={{ ...cell, width: 90 }}>단계</th>
|
||||
<th style={{ ...cell, width: 130 }}>서비스</th>
|
||||
<th style={cell}>처리시각 (YYYYMMDDHH24MISS)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{TIMELINE_STAGES.map((s) => {
|
||||
const v = (inquiry as any)[s.key]
|
||||
const has = typeof v === 'number' && v > 0
|
||||
return (
|
||||
<tr key={s.key} style={{ color: has ? undefined : '#bbb' }}>
|
||||
<td style={{ ...cell, fontWeight: 600 }}>{s.emoji} {s.label}</td>
|
||||
<td style={cell}>{s.svc}</td>
|
||||
<td style={{ ...cell, fontFamily: 'monospace' }}>{has ? fmtTs(v) : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 관련 전문 탭: pacs.008(원문) / pacs.002(결과통보) */}
|
||||
{inquiry && !(inquiry as any).note && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 6, borderBottom: '1px solid #ddd' }}>
|
||||
{([['008', '📄 pacs.008 (원문)'], ['002', `📨 pacs.002 (결과통보${notifs.length ? ` ${notifs.length}` : ''})`]] as const).map(([id, label]) => (
|
||||
<button key={id} onClick={() => setMsgTab(id)}
|
||||
style={{ padding: '8px 14px', border: 0, borderBottom: msgTab === id ? '3px solid #0b63c4' : '3px solid transparent',
|
||||
background: 'transparent', cursor: 'pointer', fontWeight: msgTab === id ? 700 : 400, color: msgTab === id ? '#0b63c4' : '#555' }}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{msgTab === '008' && (rawXml
|
||||
? <pre style={{ background: '#f5f5f5', padding: 12, marginTop: 8, overflowX: 'auto', fontSize: 13 }}>{prettyXml(rawXml)}</pre>
|
||||
: <p style={{ color: '#888', marginTop: 8 }}>(원문 pacs.008 없음 — 접수 단계에서 반려되었거나 미저장)</p>)}
|
||||
|
||||
{msgTab === '002' && (notifs.length > 0
|
||||
? notifs.map((n, i) => (
|
||||
<div key={i} style={{ marginTop: 10, border: '1px solid #e2e2e2', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ background: '#f4f7fb', padding: '8px 12px', fontSize: 13, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span>대상기관 <b>{String(n.toOrg ?? '-')}</b></span>
|
||||
<span>역할 <b>{n.role === 'APPLICANT' ? '신청기관' : n.role === 'BENEFICIARY' ? '수취기관' : String(n.role ?? '-')}</b></span>
|
||||
<span>최종상태 <b style={{ color: statusColor(String(n.finalStatus)) }}>{String(n.finalStatus ?? '-')}</b></span>
|
||||
<span>송부 {n.delivered ? '✅ 완료' : '⏳ 대기'}</span>
|
||||
</div>
|
||||
{n.pacs002
|
||||
? <pre style={{ background: '#f5f5f5', padding: 12, margin: 0, overflowX: 'auto', fontSize: 13 }}>{prettyXml(String(n.pacs002))}</pre>
|
||||
: <p style={{ color: '#888', padding: 12, margin: 0 }}>(pacs.002 전문 없음)</p>}
|
||||
</div>
|
||||
))
|
||||
: <p style={{ color: '#888', marginTop: 8 }}>(결과통보 없음 — 아직 미완결이거나 접수 단계 반려)</p>)}
|
||||
</div>
|
||||
)}
|
||||
{inquiry && rawXml === null && <p style={{ color: '#888', marginTop: 8 }}>(원문 전문 없음 — 접수 단계에서 반려되었거나 미저장)</p>}
|
||||
</section>
|
||||
|
||||
{/* 계좌 잔액 */}
|
||||
|
||||
211
frontend/src/Portal.tsx
Normal file
211
frontend/src/Portal.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAuth, jget } from './auth'
|
||||
import LoginGate from './AdminLogin'
|
||||
import { box } from './util'
|
||||
|
||||
type View = 'portal' | 'ops' | 'admin'
|
||||
type Health = { service: string; center: string; pid: number; lastSeen: number; ageMs: number; status: string }
|
||||
type SvcMetric = { service: string; port: number; reachable: boolean; metrics: Record<string, any> }
|
||||
|
||||
/** 6개 주 서비스 카드 메타(순번기는 흐름도 진입 노드로만 표기). 이름은 영문, 역할 설명은 한글 유지. */
|
||||
const SERVICES: { id: string; name: string; role: string; emoji: string; color: string }[] = [
|
||||
{ id: 'gucci', name: 'Gucci', role: '게이트웨이 · 기관 인증', emoji: '🛡️', color: '#6b4fbb' },
|
||||
{ id: 'chanel', name: 'Chanel', role: '접수', emoji: '📥', color: '#0b63c4' },
|
||||
{ id: 'hermes', name: 'Hermes', role: '결제 · 원장 엔진', emoji: '💰', color: '#c77d0a' },
|
||||
{ id: 'dior', name: 'Dior', role: '센터간 접수 동기화', emoji: '🔄', color: '#8a4fd0' },
|
||||
{ id: 'prada', name: 'Prada', role: '센터간 처리 동기화 · 완결', emoji: '✅', color: '#b8860b' },
|
||||
{ id: 'louisvuitton', name: 'Louis Vuitton', role: '관리자 · 운영', emoji: '🛠️', color: '#1a7f37' },
|
||||
]
|
||||
|
||||
/** 업무 흐름도 노드(순번기→관문→접수→동기화→결제→처리동기화→통보). 서비스명 영문, 단계 설명 한글. */
|
||||
const FLOW: { id: string; label: string }[] = [
|
||||
{ id: 'sequencer', label: 'Sequencer' }, { id: 'gucci', label: 'Gucci(관문)' }, { id: 'chanel', label: 'Chanel(접수)' },
|
||||
{ id: 'dior', label: 'Dior(동기화)' }, { id: 'hermes', label: 'Hermes(결제)' }, { id: 'prada', label: 'Prada(처리동기화)' },
|
||||
{ id: 'chanel', label: '결과통보' },
|
||||
]
|
||||
const ALL_IDS = ['sequencer', 'gucci', 'chanel', 'dior', 'hermes', 'prada', 'louisvuitton']
|
||||
|
||||
const stColor = (s: string) => (s === 'UP' ? '#1a7f37' : s === 'STALE' ? '#b58900' : '#c0392b')
|
||||
const stDot = (s: string) => (s === 'UP' ? '🟢' : s === 'STALE' ? '🟡' : '🔴')
|
||||
|
||||
export default function Portal({ onNavigate }: { onNavigate?: (v: View) => void }) {
|
||||
return <LoginGate><PortalInner onNavigate={onNavigate} /></LoginGate>
|
||||
}
|
||||
|
||||
function PortalInner({ onNavigate }: { onNavigate?: (v: View) => void }) {
|
||||
const { token } = useAuth()
|
||||
const [health, setHealth] = useState<Health[]>([])
|
||||
const [metrics, setMetrics] = useState<SvcMetric[]>([])
|
||||
const [summary, setSummary] = useState<any>(null)
|
||||
const [core, setCore] = useState<string>('?') // Gucci가 본 코어(Chanel) 상태
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [auto, setAuto] = useState(true)
|
||||
const [at, setAt] = useState<number>(0)
|
||||
const prevTx = useRef<{ n: number; at: number } | null>(null)
|
||||
const [tps, setTps] = useState(0)
|
||||
|
||||
const load = async () => {
|
||||
setErr(null)
|
||||
try {
|
||||
const [h, m, s, c] = await Promise.all([
|
||||
jget('/admin/health'),
|
||||
jget('/admin/metrics/summary'),
|
||||
jget('/admin/summary'),
|
||||
jget('/gucci/health/centers').catch(() => null),
|
||||
])
|
||||
setHealth(h); setMetrics(m?.services ?? []); setSummary(s)
|
||||
setCore(c?.centers?.[0]?.['core(chanel)'] ?? '?')
|
||||
const now = Date.now()
|
||||
const txn = Number(s?.transfers ?? 0)
|
||||
if (prevTx.current) {
|
||||
const dt = (now - prevTx.current.at) / 1000
|
||||
if (dt > 0) setTps(Math.max(0, Math.round(((txn - prevTx.current.n) / dt) * 10) / 10))
|
||||
}
|
||||
prevTx.current = { n: txn, at: now }
|
||||
setAt(now)
|
||||
} catch (e: any) { setErr('포탈 데이터 로드 실패: ' + e.message) }
|
||||
}
|
||||
|
||||
useEffect(() => { if (token) load() }, [token])
|
||||
useEffect(() => {
|
||||
if (!auto || !token) return
|
||||
const t = setInterval(load, 5000)
|
||||
return () => clearInterval(t)
|
||||
}, [auto, token])
|
||||
|
||||
const hMap: Record<string, Health> = Object.fromEntries(health.map((h) => [h.service, h]))
|
||||
const mMap: Record<string, SvcMetric> = Object.fromEntries(metrics.map((m) => [m.service, m]))
|
||||
const statusOf = (id: string): string => {
|
||||
const h = hMap[id]
|
||||
if (h) return h.status
|
||||
if (mMap[id]?.reachable) return 'UP'
|
||||
return 'DOWN'
|
||||
}
|
||||
const upCount = ALL_IDS.filter((id) => statusOf(id) === 'UP').length
|
||||
|
||||
/** transfer 상태별 건수(DB 집계 /admin/summary.byStatus). 초기화 시 자연히 0. */
|
||||
const statusCount = (st: string): number => {
|
||||
const row = (summary?.byStatus ?? []).find((r: any) => r.status === st)
|
||||
return row ? Number(row.cnt) : 0
|
||||
}
|
||||
|
||||
/** 서비스별 지표 라인. 처리 건수는 DB 집계 기반(초기화 반영), 성능/자원은 런타임 지표. */
|
||||
const metricLines = (id: string): { label: string; value: string }[] => {
|
||||
const m = mMap[id]?.metrics ?? {}
|
||||
const acccN = statusCount('ACCC')
|
||||
switch (id) {
|
||||
case 'gucci':
|
||||
return [{ label: '코어(샤넬)', value: core }, { label: '등록기관', value: String(summary?.accounts ?? '-') }]
|
||||
case 'chanel':
|
||||
return [{ label: '누적 접수', value: String(summary?.transfers ?? '-') }, { label: '접수율', value: `${tps} 건/s` }]
|
||||
case 'hermes':
|
||||
return [
|
||||
{ label: '결제완료(ACCC)', value: String(acccN) },
|
||||
{ label: '평균 처리', value: `${m.settleMeanMs ?? 0} ms` },
|
||||
{ label: '미결(대기)', value: String(Math.max(0, Number(summary?.transfers ?? 0) - acccN - statusCount('RJCT'))) },
|
||||
]
|
||||
case 'prada':
|
||||
return [{ label: '완결(ACCC)', value: String(acccN) }, { label: '반려(RJCT)', value: String(statusCount('RJCT')) }]
|
||||
case 'dior':
|
||||
return [{ label: '메모리', value: m.memUsedMb != null ? `${m.memUsedMb} MB` : '-' }, { label: '가동', value: m.uptimeSec != null ? `${m.uptimeSec}s` : '-' }]
|
||||
case 'louisvuitton':
|
||||
return [{ label: '총 거래', value: String(summary?.transfers ?? '-') }, { label: '최대순번', value: String(summary?.maxSeq ?? '-') }]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 서비스별 바로가기 버튼. */
|
||||
const actions = (id: string): { label: string; onClick: () => void }[] => {
|
||||
switch (id) {
|
||||
case 'gucci':
|
||||
return [{ label: '감사로그(Kibana)', onClick: () => window.open('http://localhost:5601', '_blank') }]
|
||||
case 'chanel':
|
||||
return [{ label: '이체 신청/조회', onClick: () => onNavigate?.('ops') }]
|
||||
case 'hermes':
|
||||
return [{ label: '원장 보기', onClick: () => onNavigate?.('admin') }]
|
||||
case 'dior':
|
||||
return [{ label: '원장 보기', onClick: () => onNavigate?.('admin') }]
|
||||
case 'prada':
|
||||
return [{ label: '완결/조회사본', onClick: () => onNavigate?.('admin') }]
|
||||
case 'louisvuitton':
|
||||
return [
|
||||
{ label: '관리자 콘솔', onClick: () => onNavigate?.('admin') },
|
||||
{ label: '로그(Kibana)', onClick: () => window.open('http://localhost:5601', '_blank') },
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const btn: React.CSSProperties = { padding: '6px 12px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: '#fff', fontSize: 13 }
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 상단 바 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginTop: 8, padding: '10px 14px', background: '#f4f7fb', borderRadius: 8 }}>
|
||||
<b style={{ fontSize: 15 }}>🏛 RTGS 포탈</b>
|
||||
<span style={{ color: '#666' }}>DC1</span>
|
||||
<span style={{ color: stColor(upCount === ALL_IDS.length ? 'UP' : upCount === 0 ? 'DOWN' : 'STALE'), fontWeight: 600 }}>
|
||||
● {upCount}/{ALL_IDS.length} UP
|
||||
</span>
|
||||
<button onClick={load} style={btn}>새로고침</button>
|
||||
<label style={{ fontSize: 13, color: '#444' }}>
|
||||
<input type="checkbox" checked={auto} onChange={(e) => setAuto(e.target.checked)} /> 자동갱신 5s
|
||||
</label>
|
||||
{at > 0 && <span style={{ color: '#999', fontSize: 12 }}>업데이트 {new Date(at).toLocaleTimeString('ko-KR')}</span>}
|
||||
</div>
|
||||
|
||||
{/* 업무 흐름도 — 창 폭에 맞춰 결과통보까지 한 화면에(가로 스크롤 없음). */}
|
||||
<section style={{ ...box }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 10 }}>업무 흐름</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, width: '100%' }}>
|
||||
{FLOW.map((n, i) => {
|
||||
const s = statusOf(n.id)
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, padding: '8px 6px', borderRadius: 8, border: `2px solid ${stColor(s)}`, background: '#fff', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{stDot(s)} {n.label}</div>
|
||||
</div>
|
||||
{i < FLOW.length - 1 && <span style={{ color: '#999', fontSize: 18, flex: '0 0 auto' }}>→</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ color: '#888', fontSize: 12, marginTop: 8 }}>전역순번(Sequencer) 부여 → 관문 인증(Gucci) → 접수(Chanel) → 저널/센터 동기화(Dior) → 결제·원장(Hermes) → 처리 동기화·완결(Prada) → 결과통보(Chanel)</div>
|
||||
</section>
|
||||
|
||||
{/* 서비스 카드 6장 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 14, marginTop: 20 }}>
|
||||
{SERVICES.map((svc) => {
|
||||
const s = statusOf(svc.id)
|
||||
const h = hMap[svc.id]
|
||||
return (
|
||||
<div key={svc.id} style={{ border: `1px solid #e2e2e2`, borderTop: `4px solid ${svc.color}`, borderRadius: 8, padding: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{svc.emoji} {svc.name}</div>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: stColor(s) }}>{stDot(s)} {s}</span>
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 13, margin: '2px 0 6px' }}>{svc.role}</div>
|
||||
<div style={{ color: '#999', fontSize: 11, fontFamily: 'monospace' }}>
|
||||
:{mMap[svc.id]?.port ?? '-'}{h ? ` · pid ${h.pid} · ${Math.round(h.ageMs / 1000)}s` : ' · 하트비트 없음'}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{metricLines(svc.id).map((ml, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span style={{ color: '#777' }}>{ml.label}</span><b style={{ fontFamily: 'monospace' }}>{ml.value}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{actions(svc.id).map((a, i) => <button key={i} onClick={a.onClick} style={btn}>{a.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{err && <p style={{ color: '#c00', marginTop: 16 }}>{err}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
frontend/src/auth.ts
Normal file
76
frontend/src/auth.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// 관리자(ADMIN JWT) 인증 공용 모듈 — 관리자 패널·포탈이 공유.
|
||||
// 토큰은 Gucci(/gucci/auth/login)에서 발급받아 localStorage에 보관하고, /admin/* 호출에 Bearer로 첨부한다.
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const TOKEN_KEY = 'rtgs_admin_token'
|
||||
const USER_KEY = 'rtgs_admin_user'
|
||||
|
||||
type Listener = () => void
|
||||
const listeners = new Set<Listener>()
|
||||
const emit = () => listeners.forEach((l) => l())
|
||||
|
||||
export const getToken = () => localStorage.getItem(TOKEN_KEY)
|
||||
export const getUser = () => localStorage.getItem(USER_KEY) || ''
|
||||
export function setSession(token: string, user: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(USER_KEY, user); emit()
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); emit()
|
||||
}
|
||||
|
||||
export function authHeader(json = false): Record<string, string> {
|
||||
const h: Record<string, string> = {}
|
||||
if (json) h['Content-Type'] = 'application/json'
|
||||
const t = getToken(); if (t) h['Authorization'] = `Bearer ${t}`
|
||||
return h
|
||||
}
|
||||
|
||||
/** 401이면 토큰을 비우고 예외. */
|
||||
export async function authedFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
const r = await fetch(url, init)
|
||||
if (r.status === 401) { clearToken(); throw new Error('세션 만료 또는 권한 없음 — 다시 로그인하세요') }
|
||||
return r
|
||||
}
|
||||
|
||||
export async function jget(url: string): Promise<any> {
|
||||
const r = await authedFetch(url, { headers: authHeader() }); return r.json()
|
||||
}
|
||||
export async function jsend(url: string, body?: any, method = 'POST'): Promise<any> {
|
||||
const r = await authedFetch(url, { method, headers: authHeader(!!body), body: body ? JSON.stringify(body) : undefined })
|
||||
return r.json()
|
||||
}
|
||||
|
||||
/** 로그인 → ADMIN 검증 → 세션 저장. 성공 시 {ok:true, mustChange}. */
|
||||
export async function login(username: string, secret: string): Promise<{ ok: boolean; mustChange?: boolean; error?: string }> {
|
||||
try {
|
||||
const resp = await fetch('/gucci/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, secret }),
|
||||
})
|
||||
if (!resp.ok) return { ok: false, error: '로그인 실패 — 아이디/비밀번호를 확인하세요' }
|
||||
const r = await resp.json()
|
||||
if (r.role !== 'ADMIN') return { ok: false, error: '관리자(ADMIN) 권한이 필요합니다' }
|
||||
setSession(r.token, username)
|
||||
return { ok: true, mustChange: !!r.mustChangePassword }
|
||||
} catch (e: any) { return { ok: false, error: '로그인 실패: ' + e.message } }
|
||||
}
|
||||
|
||||
export async function changePassword(username: string, oldSecret: string, newSecret: string): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const resp = await fetch('/gucci/auth/change-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, oldSecret, newSecret }),
|
||||
})
|
||||
if (!resp.ok) return { ok: false, error: '비밀번호 변경 실패' }
|
||||
return { ok: true }
|
||||
} catch (e: any) { return { ok: false, error: '변경 실패: ' + e.message } }
|
||||
}
|
||||
|
||||
/** 토큰 상태를 구독하는 훅(여러 컴포넌트 동기화). */
|
||||
export function useAuth() {
|
||||
const [token, setToken] = useState<string | null>(() => getToken())
|
||||
useEffect(() => {
|
||||
const l = () => setToken(getToken())
|
||||
listeners.add(l)
|
||||
return () => { listeners.delete(l) }
|
||||
}, [])
|
||||
return { token, user: getUser(), logout: clearToken }
|
||||
}
|
||||
50
frontend/src/util.tsx
Normal file
50
frontend/src/util.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// 운영 콘솔/관리자/포탈 공용 포맷·스타일·테이블·전문생성 유틸.
|
||||
|
||||
export const won = (n: number) => n.toLocaleString('ko-KR') + '원'
|
||||
|
||||
/** epoch(ms) -> YYYYMMDDHH24MISS (14자리). */
|
||||
export function fmtTs(ms?: unknown): string {
|
||||
if (typeof ms !== 'number' || !ms) return '-'
|
||||
const d = new Date(ms); const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
export const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 }
|
||||
export const cell: React.CSSProperties = { padding: '5px 9px', borderBottom: '1px solid #eee', fontSize: 13 }
|
||||
export const th: React.CSSProperties = { ...cell, borderBottom: '2px solid #ccc', textAlign: 'left', background: '#fafafa' }
|
||||
|
||||
export type Row = Record<string, any>
|
||||
|
||||
/** 정식 pacs.008.001.08 전문 생성(테스트 실행용). */
|
||||
export function buildPacs008(bmi: string, from: string, to: string, amt: number): string {
|
||||
const now = new Date().toISOString().replace(/\.\d+Z$/, '')
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">` +
|
||||
`<FIToFICstmrCdtTrf><GrpHdr><MsgId>${bmi}</MsgId><CreDtTm>${now}</CreDtTm><NbOfTxs>1</NbOfTxs>` +
|
||||
`<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr><CdtTrfTxInf><PmtId><EndToEndId>${bmi}</EndToEndId></PmtId>` +
|
||||
`<IntrBkSttlmAmt Ccy="KRW">${amt}</IntrBkSttlmAmt><ChrgBr>SLEV</ChrgBr>` +
|
||||
`<Dbtr><Nm>BANK-${from}</Nm></Dbtr><DbtrAcct><Id><Othr><Id>ACC-${from}</Id></Othr></Id></DbtrAcct>` +
|
||||
`<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>${from}</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>` +
|
||||
`<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>${to}</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>` +
|
||||
`<Cdtr><Nm>BANK-${to}</Nm></Cdtr><CdtrAcct><Id><Othr><Id>ACC-${to}</Id></Othr></Id></CdtrAcct>` +
|
||||
`</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>`
|
||||
}
|
||||
|
||||
export function MiniTable({ title, cols, rows, timeCols = [] }: { title: string; cols: [string, string][]; rows: Row[]; timeCols?: string[] }) {
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{title} <span style={{ color: '#888', fontSize: 12 }}>({rows.length})</span></div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead><tr>{cols.map(([, ko]) => <th key={ko} style={th}>{ko}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>{cols.map(([k]) => <td key={k} style={{ ...cell, fontFamily: /code|seq|bmi|balance|amount/.test(k) ? 'monospace' : undefined }}>
|
||||
{timeCols.includes(k) ? fmtTs(r[k]) : String(r[k] ?? '-')}
|
||||
</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,8 +12,10 @@ export default defineConfig({
|
||||
'/pay': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/meta': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/rawmessage': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/notifications': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/ledger': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/admin': { target: 'http://localhost:8099', changeOrigin: true },
|
||||
'/console': { target: 'http://localhost:8099', changeOrigin: true },
|
||||
'/gucci': { target: 'http://localhost:8095', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user