From c96664e52d83add0b4c8b5d9c1668fb9f8b4ea3b Mon Sep 17 00:00:00 2001 From: rtgs Date: Thu, 16 Jul 2026 13:41:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=ED=8F=AC=ED=83=88=C2=B7?= =?UTF-8?q?=EC=9A=B4=EC=98=81=C2=B7=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=BD=98?= =?UTF-8?q?=EC=86=94=20=EB=B0=8F=20=EB=A7=A4=EB=89=B4=EC=96=BC=C2=B7?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=C2=B7?= =?UTF-8?q?=EC=A0=84=EB=AC=B8=ED=83=AD=C2=B7=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 콘솔을 포탈/운영/관리자 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 --- frontend/public/manual.html | 334 ++++++++++++++++++++++++++++++++++++ frontend/src/AdminLogin.tsx | 65 +++++++ frontend/src/AdminPanel.tsx | 255 +++++++++++---------------- frontend/src/App.tsx | 107 ++++++++++-- frontend/src/Portal.tsx | 211 +++++++++++++++++++++++ frontend/src/auth.ts | 76 ++++++++ frontend/src/util.tsx | 50 ++++++ frontend/vite.config.ts | 2 + 8 files changed, 933 insertions(+), 167 deletions(-) create mode 100644 frontend/public/manual.html create mode 100644 frontend/src/AdminLogin.tsx create mode 100644 frontend/src/Portal.tsx create mode 100644 frontend/src/auth.ts create mode 100644 frontend/src/util.tsx diff --git a/frontend/public/manual.html b/frontend/public/manual.html new file mode 100644 index 0000000..5643006 --- /dev/null +++ b/frontend/public/manual.html @@ -0,0 +1,334 @@ + + + + + +RTGS 포탈 · 서비스 매뉴얼 + + + +
+ +
+

🏛 RTGS 포탈 서비스 매뉴얼

+

소액 RTGS(실시간총액결제) 프로토타입 · 7개 서비스 안내 · Active-Active-Active 복원력 설계

+
+ + + +

1. 포탈 개요

+

+ 본 포탈은 소액 RTGS(Real-Time Gross Settlement, 실시간총액결제) 프로토타입의 운영·관리 콘솔입니다. + 거래는 단일 전역 순번기가 입구에서 순번을 부여하고, 각 센터가 그 순번대로 동일하게 재생(결정론적 처리)하며, + 원장(PostgreSQL)이 잔액을 강한 일관성으로 확정합니다. 3센터 Active-Active-Active 구성에서 + 정족수(2/3)와 펜싱으로 split-brain을 차단하는 것이 설계의 핵심입니다. +

+

+ 시스템은 명품 브랜드를 코드네임으로 쓰는 7개 서비스로 구성됩니다. 포탈 화면에는 이 중 + 주요 6개가 카드로 표시되고, 전역 순번기(Sequencer)는 업무 흐름도의 진입 노드로 나타납니다. + 각 서비스가 무슨 일을 하고, 카드에 표시되는 숫자가 무엇을 뜻하는지 이 문서에서 설명합니다. +

+ +

2. 화면 읽는 법

+

상태 표시등

+
+
🟢 UP — 정상 가동(최근 하트비트 수신)
+
🟡 STALE — 하트비트 지연(응답이 오래됨, 점검 필요)
+
🔴 DOWN — 중단(하트비트 없음/도달 불가)
+
+

+ 상단 바의 ● n/7 UP은 7개 서비스 중 정상 가동 중인 수를 나타냅니다. + 각 서비스는 DB의 service_heartbeat에 주기적으로 살아있음을 기록하고(헤드리스 포함), + 관리 서비스가 이를 읽어 UP/STALE/DOWN을 판정합니다. +

+

자동 갱신 · 새로고침

+

+ 자동갱신 5s 체크 시 5초마다 상태·지표를 다시 불러옵니다. + 새로고침으로 즉시 갱신할 수도 있습니다. + 카드의 처리 건수는 DB 집계(원장) 기준이라 관리자 콘솔에서 초기화하면 자연히 0으로 돌아갑니다. +

+ +

3. 업무 흐름

+

한 건의 이체는 아래 순서로 처리됩니다. 각 노드는 담당 서비스이며, 포탈 흐름도에서 색 테두리로 실시간 상태를 함께 보여줍니다.

+
+
🔢 Sequencer전역순번
+
+
🛡️ Gucci관문 인증
+
+
📥 Chanel접수
+
+
🔄 Dior동기화
+
+
💰 Hermes결제
+
+
✅ Prada처리동기화
+
+
📥 Chanel결과통보
+
+

+ 전역순번(Sequencer) 부여 → 관문 인증(Gucci) → 접수(Chanel) → 저널/센터 동기화(Dior) → + 결제·원장(Hermes) → 처리 동기화·완결(Prada) → 결과통보(Chanel). +

+ +

4. 서비스별 설명

+ + +
+

🔢 Sequencer 전역 순번기 · Headless

+
전역순번 부여 · 결정론적 값 확정 → 저널 발행
+
논리포트 8090유형 Headless JVMKafka: rtgs.inboundrtgs.journal
+

+ 시스템의 단일 진입 순서 결정자입니다. 접수된 요청에 전역순번(global_seq)을 부여해 + 단일 파티션 저널에 발행하면, 모든 센터가 이 순서를 그대로 재생해 동일한 결과를 만듭니다(결정론적 처리). + 재기동 시 MAX(global_seq)에서 이어서 발번하므로 순번 유실이 없습니다. +

+

※ 순서 결정만 담당(상태 O). 분산·라우팅은 Gucci/GSLB(상태 X)가 맡아 역할을 분리합니다. + 포탈에서는 별도 카드 없이 흐름도의 진입 노드로 표시됩니다.

+
+ + +
+

🛡️ Gucci 게이트웨이 · 기관 인증

+
외부 경계 관문 — 로그인 · JWT · API인증 · 유량제어 · 재전송차단 · 결과 콜백송부
+
논리포트 8095유형 Web(Tomcat)코드네임 Global User Communication Control Interface
+

+ 참가기관과 시스템 사이의 대문입니다. 로그인 후 JWT(HS256)를 발급하고, 모든 요청의 인증·인가 + (역할 + 기관 바인딩)를 검사합니다. 기관별 유량제어로 코어·순번기를 보호하고(백프레셔), + nonce+timestamp로 재전송을 차단합니다. 처리 결과는 기관 콜백 URL로 재시도·ACK 방식으로 송부합니다. +

+ + + + +
카드 지표의미
코어(샤넬)Gucci가 바라본 코어(Chanel) 연결 상태
등록기관당좌계좌가 등록된 참가기관 수
+
바로가기 감사로그(Kibana) — 인증·유량·재전송 등 감사 이벤트를 Kibana에서 조회
+
+ + +
+

📥 Chanel 접수

+
pacs.008 접수 · XSD검증 · 경량화 · 원전문 저장 · 결과통보(pacs.002)
+
논리포트 8091유형 Web(Tomcat)코드네임 Contact Hub · Entry Liaison
+

+ 참가기관의 결제의뢰 전문(ISO 20022 pacs.008)을 받는 접수 창구입니다. + 공식 XSD로 검증(XXE 차단)하고 원전문을 SHA-256 해시와 함께 저장한 뒤, 경량 명령문으로 만들어 + 입구 토픽에 발행합니다. 처리가 끝나면 pacs.002 결과통보 전문을 생성해 아웃박스에 적재합니다. +

+ + + + +
카드 지표의미
누적 접수지금까지 접수된 거래 건수(원장 집계)
접수율초당 접수 건수(TPS, 최근 갱신 구간 기준)
+
바로가기 이체 신청/조회 — 운영 콘솔에서 pacs.008 신청 및 거래 상태 조회
+
+ + +
+

🔄 Dior 센터간 접수 동기화

+
저널 소비 → 원장 PDNG 기록(접수 동기화)
+
논리포트 8092유형 Headless JVM코드네임 Data Integration · Relay
+

+ 전역순번 저널을 소비해 원장에 거래를 PDNG(대기) 상태로 기록합니다. 결제(Hermes)와 병행해 + 저널을 읽으면서 모든 센터의 접수 상태를 동일하게 맞추는 동기화 역할을 합니다. +

+ + + + +
카드 지표의미
메모리프로세스 사용 메모리(MB) — 런타임 자원 지표
가동프로세스 가동 시간(초)
+
바로가기 원장 보기 — 관리자 콘솔의 원장/거래 현황
+
+ + +
+

💰 Hermes 결제 · 원장 엔진

+
저널 순차소비 · 순서교정 · 선(先)저널 · 당좌 차/대변(강한 일관성) · ACSP
+
논리포트 8093유형 Headless JVM코드네임 Rapid Memory Execution · Settlement
+

+ 실제 잔액을 움직이는 결제 엔진입니다. 저널을 순번대로(동시성 1) 소비하며 순서를 교정하고, + 잔액 연산 전에 선저널(write-ahead)로 순번을 확정한 뒤 송신·수신 기관 당좌계좌를 차/대변 처리합니다 + (PostgreSQL 강한 일관성 트랜잭션 → 이중지급 0·총액 보존). 정산이 반영되면 ACSP 결과를 발행합니다. + 선저널 덕분에 엔진이 죽었다 살아나도 Kafka 오프셋 재생으로 무손실 승계됩니다. +

+ + + + + +
카드 지표의미
결제완료(ACCC)최종 완결된 거래 건수(원장 집계)
평균 처리정산 1건당 평균 소요시간(ms) — 런타임 지표
미결(대기)접수됐으나 아직 완결·반려되지 않은 건수(전체 − ACCC − RJCT)
+
바로가기 원장 보기 — 관리자 콘솔의 원장/잔액
+
+ + +
+

✅ Prada 센터간 처리 동기화 · 완결

+
결과 소비 · 완결 규율(과반) · ACCC 확정 · 조회사본 · 결과통보 발행
+
논리포트 8094유형 Headless JVM코드네임 Persistent Repository · Aggregation
+

+ 정산 결과를 소비해 완결 규율을 적용합니다. 과반(정족수) 센터가 동일 결과를 확정하면 + 최종 상태 ACCC(입금처리완료)로 확정하고 조회 전용 사본을 남깁니다. 완결이 커밋되면 + 결과통보(rtgs.notify)를 발행해 Chanel→Gucci를 통한 기관 통보로 이어집니다. +

+ + + + +
카드 지표의미
완결(ACCC)최종 완결된 거래 건수
반려(RJCT)검증 실패·잔액 부족 등으로 반려된 건수
+
바로가기 완결/조회사본 — 관리자 콘솔의 완결 현황
+
+ + +
+

🛠️ Louis Vuitton 관리자 · 운영

+
시스템관리 — DB초기화 · 코드 · 사용자권한 · 대사 대시보드
+
논리포트 8099유형 Web(Tomcat)코드네임 Leading Operations Unified Info Systems
+

+ 시스템 관리·운영 서비스입니다. DB 초기화, 상태코드·기관·사용자 권한 관리, 헬스(하트비트) 집계, + 원장=저널=조회사본 대사(對査) 대시보드를 제공합니다. 스키마는 Flyway로 형상 관리하며 기동 시 자동 적용합니다. + (관리자 콘솔은 ADMIN JWT 필수, 테스트 계정 a / 1) +

+ + + + +
카드 지표의미
총 거래원장에 기록된 전체 거래 건수
최대순번부여된 전역순번의 최댓값(MAX(global_seq))
+
바로가기 관리자 콘솔 로그(Kibana)
+
+ +

5. 거래 상태(TxSts) 전이

+

한 건의 거래는 아래 상태를 따라 진행됩니다.

+ + + + + + + + +
상태한글담당설명
RCVD접수Chanel전문 접수·검증 통과
ACTC순번Sequencer전역순번 부여
PDNG대기Dior원장에 대기 기록(접수 동기화)
ACSP정산반영Hermes당좌 차/대변 반영
ACCC입금처리완료Prada과반 확정 · 최종 완결
RJCT반려Chanel/HermesXSD·업무규칙 실패 또는 잔액부족·미등록기관
+
+ 정상 흐름: RCVD → ACTC → PDNG → ACSP → ACCC. + 검증·잔액 문제 시 RJCT로 종료됩니다. +
+ +

6. 포트 일람

+ + + + + + + + + + + + + +
서비스포트유형
🔢 Sequencer8090Headless JVM (논리)
📥 Chanel8091Web (Tomcat)
🔄 Dior8092Headless JVM (논리)
💰 Hermes8093Headless JVM (논리)
✅ Prada8094Headless JVM (논리)
🛡️ Gucci8095Web (Tomcat)
🛠️ Louis Vuitton8099Web (Tomcat)
운영/관리 콘솔 (Frontend)5174React + Vite
PostgreSQL (원장)5433RDBMS
Kafka (입구/저널/결과/통보)9092KRaft
Elasticsearch / Logstash / Kibana9200 / 5000 / 5601ELK 로그
+ +

7. 용어

+ + + + + + + + + + +
약어의미
RTGSReal-Time Gross Settlement — 실시간총액결제
A-A-AActive-Active-Active — 3센터 동시 가동
BMIBusiness Message Identifier — 거래식별자 22자리(영업일8+기관4+일련10)
저널전역순번이 부여된 처리 명단(Kafka 단일 파티션)
정족수Quorum(N/2+1). 3센터=2
선저널Write-ahead journal — 잔액연산 전 순번 확정(무손실 승계)
pacs.008 / pacs.002ISO 20022 결제의뢰 / 결과통보 전문
TPSTransactions Per Second — 초당 처리 건수
+ +
+ RTGS 프로토타입 · 운영/관리 콘솔 서비스 매뉴얼 · 작성 루비(AI) · 검토 양희정 팀장(한국은행 RTGS시스템팀)
+ 상세 설계는 docs/RTGS 아키텍처 설계서.md 및 워크플로우 문서를 참조하세요. +
+ +
+ + diff --git a/frontend/src/AdminLogin.tsx b/frontend/src/AdminLogin.tsx new file mode 100644 index 0000000..6eefc62 --- /dev/null +++ b/frontend/src/AdminLogin.tsx @@ -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(null) + const [msg, setMsg] = useState(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 ( +
+

🔐 관리자 로그인

+

시스템관리(LouisVuitton)·포탈 상태 조회는 ADMIN 로그인이 필요합니다.

+
+ setForm({ ...form, username: e.target.value })} style={{ padding: 8 }} /> + setForm({ ...form, secret: e.target.value })} + onKeyDown={(e) => { if (e.key === 'Enter') doLogin() }} style={{ padding: 8 }} /> + +
+ {err &&

{err}

} +
+ ) + } + if (mustChange) { + return ( +
+

🔑 초기 비밀번호 변경

+

본격 사용 전 초기 비밀번호를 변경해야 합니다.

+
+ setNewSecret(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') doChange() }} style={{ padding: 8 }} /> + + +
+ {msg &&

{msg}

} + {err &&

{err}

} +
+ ) + } + return <>{children} +} diff --git a/frontend/src/AdminPanel.tsx b/frontend/src/AdminPanel.tsx index bd8e288..d985376 100644 --- a/frontend/src/AdminPanel.tsx +++ b/frontend/src/AdminPanel.tsx @@ -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 - -/** 정식 pacs.008.001.08 전문 생성(테스트 실행용). */ -function buildPacs008(bmi: string, from: string, to: string, amt: number): string { - const now = new Date().toISOString().replace(/\.\d+Z$/, '') - return `` + - `${bmi}${now}1` + - `CLRG${bmi}` + - `${amt}SLEV` + - `BANK-${from}ACC-${from}` + - `${from}` + - `${to}` + - `BANK-${to}ACC-${to}` + - `` -} - -function MiniTable({ title, cols, rows, timeCols = [] }: { title: string; cols: [string, string][]; rows: Row[]; timeCols?: string[] }) { - return ( -
-
{title} ({rows.length})
-
- - {cols.map(([, ko]) => )} - - {rows.map((r, i) => ( - {cols.map(([k]) => )} - ))} - -
{ko}
- {timeCols.includes(k) ? fmtTs(r[k]) : String(r[k] ?? '-')} -
-
-
- ) -} +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(null) const [transfers, setTransfers] = useState([]) const [journal, setJournal] = useState([]) @@ -54,92 +12,74 @@ export default function AdminPanel() { const [insts, setInsts] = useState([]) const [codes, setCodes] = useState([]) const [users, setUsers] = useState([]) - const [health, setHealth] = useState([]) + const [services, setServices] = useState(null) + const [svcLog, setSvcLog] = useState<{ id: string; lines: string[] } | null>(null) + const [busySvc, setBusySvc] = useState(null) const [msg, setMsg] = useState(null) const [err, setErr] = useState(null) - // 인증(A1): Gucci 로그인으로 발급받은 ADMIN JWT - const [token, setToken] = useState(() => localStorage.getItem('rtgs_admin_token')) - const [loginUser, setLoginUser] = useState(() => 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 => { - const h: Record = {} - 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 ( -
-

🔐 관리자 로그인

-

시스템관리(LouisVuitton) 콘솔은 ADMIN 로그인이 필요합니다.
테스트 계정: a / 1

-
- setLoginForm({ ...loginForm, username: e.target.value })} style={{ padding: 8 }} /> - setLoginForm({ ...loginForm, secret: e.target.value })} - onKeyDown={(e) => { if (e.key === 'Enter') doLogin() }} style={{ padding: 8 }} /> - -
- {err &&

{err}

} -
- ) - } - if (mustChange) { - return ( -
-

🔑 초기 비밀번호 변경

-

본격 사용 전 초기 비밀번호를 변경해야 합니다.

-
- setNewSecret(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') doChangePassword() }} style={{ padding: 8 }} /> - - -
- {err &&

{err}

} -
- ) - } - return ( +
- 👤 {loginUser} (ADMIN) + 👤 {user} (ADMIN)
- {/* 서비스 상태(B5 하트비트) */} + {/* 서비스 제어 (기동/중지/재기동 · 하트비트 상태 · 4초 갱신) */}
-

🩺 서비스 상태 (하트비트 기반 · 헤드리스 포함)

-
- {health.length === 0 && 하트비트 수집 대기…} - {health.map((h) => ( - - {h.status === 'UP' ? '🟢' : '🔴'} {String(h.service)} ({String(h.center)}·pid {String(h.pid)}·{Math.round(Number(h.ageMs) / 1000)}s) - - ))} -
+

🖥️ 서비스 제어 (기동·중지·재기동 · 하트비트 기반 · 4초 자동 갱신)

+ {services?.controlEnabled === false &&

⚠ 제어 비활성화(rtgs.control.enabled=false) — 상태 조회만 가능

} + {!services && 상태 수집 대기…} + {services && ( + + + + + + {(services.services ?? []).map((s: any) => ( + + + + + + + + ))} + {(services.infra ?? []).map((f: any) => ( + + + + + + + + ))} + +
상태서비스포트PID제어
+ {s.status === 'UP' ? '🟢 UP' : '🔴 DOWN'} + {s.status === 'UP' && s.ageMs != null && · {Math.round(s.ageMs / 1000)}s} + {s.name}:{s.port}{s.pid ?? '-'} + {s.controllable ? ( + + + + + + + ) : 선행 필수(스크립트로 기동)} +
{f.reachable ? '🟢 UP' : '🔴 DOWN'}{f.name} (인프라):{f.port}-선행 필수(스크립트로 기동)
+ )} + {svcLog && ( +
+ 📜 {svcLog.id}.log (최근 {svcLog.lines.length}줄) +
{svcLog.lines.join('\n') || '(로그 없음)'}
+
+ )}
{/* 대사 대시보드 */} @@ -283,12 +229,12 @@ export default function AdminPanel() {

👤 사용자 권한 관리 (관리 마스터 · ADMIN 로그인 적용됨)

- - - + + - +
@@ -322,5 +268,6 @@ export default function AdminPanel() { {msg &&

{msg}

} {err &&

{err}

} + ) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5be1c6d..3880daa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 @@ -26,6 +27,16 @@ const STATUS_KO: Record = { 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(null) const [rawXml, setRawXml] = useState(null) + const [notifs, setNotifs] = useState[]>([]) + 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 ( -
-

RTGS 콘솔 (DC1 · :5174)

+
+
+

RTGS 콘솔 (DC1 · :5174)

+ + 📖 서비스 매뉴얼 + +
+
+ {view === 'portal' && } {view === 'admin' && } {view === 'ops' && (<> @@ -208,7 +233,7 @@ export default function App() {
- {Object.entries(inquiry).filter(([k]) => k !== 'note').map(([k, v]) => ( + {Object.entries(inquiry).filter(([k]) => k !== 'note' && !TIMELINE_KEYS.has(k)).map(([k, v]) => ( @@ -219,13 +244,69 @@ export default function App() {
{labels[k] ?? k} {k}
)} - {rawXml && ( -
- 📄 원문 ISO20022 pacs.008 전문 -
{prettyXml(rawXml)}
-
+ {/* 서비스별 처리시각 타임라인(접수→순번→기록→정산→완결) */} + {inquiry && !(inquiry as any).note && ( +
+
🕘 서비스별 처리시각
+ + + + + + + + + + {TIMELINE_STAGES.map((s) => { + const v = (inquiry as any)[s.key] + const has = typeof v === 'number' && v > 0 + return ( + + + + + + ) + })} + +
단계서비스처리시각 (YYYYMMDDHH24MISS)
{s.emoji} {s.label}{s.svc}{has ? fmtTs(v) : '—'}
+
+ )} + + {/* 관련 전문 탭: pacs.008(원문) / pacs.002(결과통보) */} + {inquiry && !(inquiry as any).note && ( +
+
+ {([['008', '📄 pacs.008 (원문)'], ['002', `📨 pacs.002 (결과통보${notifs.length ? ` ${notifs.length}` : ''})`]] as const).map(([id, label]) => ( + + ))} +
+ + {msgTab === '008' && (rawXml + ?
{prettyXml(rawXml)}
+ :

(원문 pacs.008 없음 — 접수 단계에서 반려되었거나 미저장)

)} + + {msgTab === '002' && (notifs.length > 0 + ? notifs.map((n, i) => ( +
+
+ 대상기관 {String(n.toOrg ?? '-')} + 역할 {n.role === 'APPLICANT' ? '신청기관' : n.role === 'BENEFICIARY' ? '수취기관' : String(n.role ?? '-')} + 최종상태 {String(n.finalStatus ?? '-')} + 송부 {n.delivered ? '✅ 완료' : '⏳ 대기'} +
+ {n.pacs002 + ?
{prettyXml(String(n.pacs002))}
+ :

(pacs.002 전문 없음)

} +
+ )) + :

(결과통보 없음 — 아직 미완결이거나 접수 단계 반려)

)} +
)} - {inquiry && rawXml === null &&

(원문 전문 없음 — 접수 단계에서 반려되었거나 미저장)

}
{/* 계좌 잔액 */} diff --git a/frontend/src/Portal.tsx b/frontend/src/Portal.tsx new file mode 100644 index 0000000..75afe3d --- /dev/null +++ b/frontend/src/Portal.tsx @@ -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 } + +/** 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 +} + +function PortalInner({ onNavigate }: { onNavigate?: (v: View) => void }) { + const { token } = useAuth() + const [health, setHealth] = useState([]) + const [metrics, setMetrics] = useState([]) + const [summary, setSummary] = useState(null) + const [core, setCore] = useState('?') // Gucci가 본 코어(Chanel) 상태 + const [err, setErr] = useState(null) + const [auto, setAuto] = useState(true) + const [at, setAt] = useState(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 = Object.fromEntries(health.map((h) => [h.service, h])) + const mMap: Record = 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 ( +
+ {/* 상단 바 */} +
+ 🏛 RTGS 포탈 + DC1 + + ● {upCount}/{ALL_IDS.length} UP + + + + {at > 0 && 업데이트 {new Date(at).toLocaleTimeString('ko-KR')}} +
+ + {/* 업무 흐름도 — 창 폭에 맞춰 결과통보까지 한 화면에(가로 스크롤 없음). */} +
+
업무 흐름
+
+ {FLOW.map((n, i) => { + const s = statusOf(n.id) + return ( +
+
+
{stDot(s)} {n.label}
+
+ {i < FLOW.length - 1 && } +
+ ) + })} +
+
전역순번(Sequencer) 부여 → 관문 인증(Gucci) → 접수(Chanel) → 저널/센터 동기화(Dior) → 결제·원장(Hermes) → 처리 동기화·완결(Prada) → 결과통보(Chanel)
+
+ + {/* 서비스 카드 6장 */} +
+ {SERVICES.map((svc) => { + const s = statusOf(svc.id) + const h = hMap[svc.id] + return ( +
+
+
{svc.emoji} {svc.name}
+ {stDot(s)} {s} +
+
{svc.role}
+
+ :{mMap[svc.id]?.port ?? '-'}{h ? ` · pid ${h.pid} · ${Math.round(h.ageMs / 1000)}s` : ' · 하트비트 없음'} +
+
+ {metricLines(svc.id).map((ml, i) => ( +
+ {ml.label}{ml.value} +
+ ))} +
+
+ {actions(svc.id).map((a, i) => )} +
+
+ ) + })} +
+ + {err &&

{err}

} +
+ ) +} diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts new file mode 100644 index 0000000..3691b4f --- /dev/null +++ b/frontend/src/auth.ts @@ -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() +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 { + const h: Record = {} + 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 { + const r = await fetch(url, init) + if (r.status === 401) { clearToken(); throw new Error('세션 만료 또는 권한 없음 — 다시 로그인하세요') } + return r +} + +export async function jget(url: string): Promise { + const r = await authedFetch(url, { headers: authHeader() }); return r.json() +} +export async function jsend(url: string, body?: any, method = 'POST'): Promise { + 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(() => getToken()) + useEffect(() => { + const l = () => setToken(getToken()) + listeners.add(l) + return () => { listeners.delete(l) } + }, []) + return { token, user: getUser(), logout: clearToken } +} diff --git a/frontend/src/util.tsx b/frontend/src/util.tsx new file mode 100644 index 0000000..43472fc --- /dev/null +++ b/frontend/src/util.tsx @@ -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 + +/** 정식 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 `` + + `${bmi}${now}1` + + `CLRG${bmi}` + + `${amt}SLEV` + + `BANK-${from}ACC-${from}` + + `${from}` + + `${to}` + + `BANK-${to}ACC-${to}` + + `` +} + +export function MiniTable({ title, cols, rows, timeCols = [] }: { title: string; cols: [string, string][]; rows: Row[]; timeCols?: string[] }) { + return ( +
+
{title} ({rows.length})
+
+ + {cols.map(([, ko]) => )} + + {rows.map((r, i) => ( + {cols.map(([k]) => )} + ))} + +
{ko}
+ {timeCols.includes(k) ? fmtTs(r[k]) : String(r[k] ?? '-')} +
+
+
+ ) +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9991107..9d133ca 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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 }, }, },