Initialize RTGS prototype repository
3센터 A-A-A RTGS 실시간총액결제 프로토타입 최초 버전관리 시작. - backend: Kotlin/Gradle 멀티모듈(sequencer, common, 채널/센터 모듈) - frontend: Vite + TS - infra: docker-compose, prometheus/grafana, ELK 네이티브 스크립트 - loadtest(k6), sim(장애/순서/정합성 시나리오), docs(PoC 보고서/복원력방안)
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RTGS 조회 콘솔</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1771
frontend/package-lock.json
generated
Normal file
1771
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "rtgs-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
326
frontend/src/AdminPanel.tsx
Normal file
326
frontend/src/AdminPanel.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [summary, setSummary] = useState<any>(null)
|
||||
const [transfers, setTransfers] = useState<Row[]>([])
|
||||
const [journal, setJournal] = useState<Row[]>([])
|
||||
const [views, setViews] = useState<Row[]>([])
|
||||
const [insts, setInsts] = useState<Row[]>([])
|
||||
const [codes, setCodes] = useState<Row[]>([])
|
||||
const [users, setUsers] = useState<Row[]>([])
|
||||
const [health, setHealth] = useState<Row[]>([])
|
||||
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: '' })
|
||||
|
||||
// 테스트 실행(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'),
|
||||
])
|
||||
setSummary(s); setTransfers(t); setJournal(jn); setViews(v); setInsts(ins); setCodes(cd); setUsers(us); setHealth(hl)
|
||||
} catch (e: any) { setErr('관리자 데이터 로드 실패: ' + e.message) }
|
||||
}
|
||||
useEffect(() => { if (token && !mustChange) loadAll() }, [token, mustChange])
|
||||
|
||||
const doReset = async () => {
|
||||
if (!confirm('정말 초기화할까요?\n거래/저널/조회사본/원전문을 모두 비우고 계좌 잔액을 각 10억으로 되돌립니다.')) return
|
||||
setMsg(null); setErr(null)
|
||||
try { const r = await post('/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) }
|
||||
}
|
||||
const delInst = async (code: string) => { if (confirm(`기관 ${code} 삭제?`)) { await post('/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) }
|
||||
}
|
||||
const delUser = async (u: string) => { if (confirm(`사용자 ${u} 삭제?`)) { await post('/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']
|
||||
const runTest = async () => {
|
||||
setErr(null); setMsg(null)
|
||||
const n = Number(test.count)
|
||||
if (!n || n < 1) { setErr('건수를 1 이상 입력하세요'); return }
|
||||
let base: bigint
|
||||
try { base = BigInt((test.bmiStart || '').trim()) } catch { setErr('BMI 시작번호는 숫자여야 합니다'); return }
|
||||
const banks = insts.length ? insts.map((a) => String(a.code)) : BANKS_FALLBACK
|
||||
const st = { running: true, done: 0, sent: n, rcvd: 0, rjct: 0, err: 0 }
|
||||
setTestRun({ ...st })
|
||||
const one = async (i: number) => {
|
||||
const from = banks[Math.floor(Math.random() * banks.length)]
|
||||
let to = from, guard = 0
|
||||
while (to === from && guard++ < 20) to = banks[Math.floor(Math.random() * banks.length)]
|
||||
const amt = 1000 + Math.floor(Math.random() * (1000000 - 1000 + 1))
|
||||
const bmi = (base + BigInt(i)).toString().padStart(22, '0')
|
||||
try {
|
||||
const r = await fetch('/pay/customer', { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: buildPacs008(bmi, from, to, amt) })
|
||||
const txt = await r.text()
|
||||
if (txt.includes('RCVD')) st.rcvd++; else st.rjct++
|
||||
} catch { st.err++ }
|
||||
st.done++
|
||||
if (st.done % 10 === 0 || st.done === n) setTestRun({ ...st })
|
||||
}
|
||||
const CONC = 20 // 브라우저 동시요청 배치
|
||||
for (let i = 0; i < n; i += CONC) {
|
||||
await Promise.all(Array.from({ length: Math.min(CONC, n - i) }, (_, k) => one(i + k)))
|
||||
}
|
||||
st.running = false; setTestRun({ ...st })
|
||||
setMsg(`테스트 완료: 접수 ${st.rcvd} · 반려 ${st.rjct} · 오류 ${st.err} (완결은 대시보드 새로고침으로 확인)`)
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div style={{ textAlign: 'right', color: '#666', fontSize: 13, marginTop: 8 }}>
|
||||
👤 <b>{loginUser}</b> (ADMIN) <button onClick={logout} style={{ fontSize: 12, marginLeft: 8 }}>로그아웃</button>
|
||||
</div>
|
||||
{/* 서비스 상태(B5 하트비트) */}
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* 대사 대시보드 */}
|
||||
<section style={{ ...box, background: '#f7fbff' }}>
|
||||
<h2 style={{ marginTop: 0 }}>📊 대사(정합성) 대시보드 <button onClick={loadAll} style={{ fontSize: 12 }}>새로고침</button></h2>
|
||||
{summary && (
|
||||
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
||||
<div>총 거래: <b>{summary.transfers}</b></div>
|
||||
<div>최대 전역순번: <b>{summary.maxSeq}</b></div>
|
||||
<div>계좌수: <b>{summary.accounts}</b></div>
|
||||
<div>총액: <b style={{ fontFamily: 'monospace' }}>{won(summary.totalBalance)}</b> {totalOk ? <span style={{ color: '#1a7f37' }}>✓ 보존</span> : <span style={{ color: '#c00' }}>⚠ 확인</span>}</div>
|
||||
<div>상태분포: {summary.byStatus?.map((b: any) => <span key={b.status} style={{ marginRight: 8 }}><code>{b.status}</code> {b.cnt}</span>)}</div>
|
||||
</div>
|
||||
)}
|
||||
<MiniTable title="원장(transfer) — Dior 접수→Hermes 결제→Prada 완결"
|
||||
cols={[['global_seq', '순번'], ['bmi', 'BMI'], ['status', '상태'], ['sender_code', '송신'], ['receiver_code', '수신'], ['amount', '금액'], ['updated_at', '최종수정']]}
|
||||
rows={transfers} timeCols={['updated_at']} />
|
||||
<MiniTable title="선저널(journal_log) — Hermes write-ahead"
|
||||
cols={[['global_seq', '순번'], ['bmi', 'BMI'], ['applied', '적용']]} rows={journal} />
|
||||
<MiniTable title="조회사본(settlement_view) — Prada"
|
||||
cols={[['global_seq', '순번'], ['bmi', 'BMI'], ['final_status', '최종상태'], ['debtor_balance_after', '송신잔액'], ['creditor_balance_after', '수신잔액'], ['finalized_at', '완결시각']]}
|
||||
rows={views} timeCols={['finalized_at']} />
|
||||
</section>
|
||||
|
||||
{/* DB 초기화 */}
|
||||
<section style={box}>
|
||||
<h2 style={{ marginTop: 0 }}>♻️ 테스트용 DB 초기화</h2>
|
||||
<p style={{ color: '#555' }}>거래/저널/조회사본/원전문을 모두 비우고 계좌 잔액을 각 10억으로 되돌립니다. (Kafka 저널 이력은 보존)</p>
|
||||
<button onClick={doReset} style={{ padding: '10px 20px', background: '#c0392b', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}>DB 초기화 실행</button>
|
||||
</section>
|
||||
|
||||
{/* 기관/코드 관리 */}
|
||||
<section style={box}>
|
||||
<h2 style={{ marginTop: 0 }}>🏛 코드 관리 — 참가기관 마스터</h2>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
|
||||
<label>기관코드(4자리)<br /><input value={inst.code} onChange={(e) => setInst({ ...inst, code: e.target.value })} style={{ padding: 6, width: 100, fontFamily: 'monospace' }} /></label>
|
||||
<label>기관명<br /><input value={inst.name} onChange={(e) => setInst({ ...inst, name: e.target.value })} style={{ padding: 6, width: 160 }} /></label>
|
||||
<label>초기잔액<br /><input type="number" value={inst.balance} onChange={(e) => setInst({ ...inst, balance: Number(e.target.value) })} style={{ padding: 6, width: 140 }} /></label>
|
||||
<button onClick={saveInst} style={{ padding: '8px 16px' }}>추가/수정</button>
|
||||
</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead><tr><th style={th}>코드</th><th style={th}>기관명</th><th style={{ ...th, textAlign: 'right' }}>잔액</th><th style={th}></th></tr></thead>
|
||||
<tbody>{insts.map((a) => (
|
||||
<tr key={a.code}>
|
||||
<td style={{ ...cell, fontFamily: 'monospace' }}>{a.code}</td><td style={cell}>{a.name}</td>
|
||||
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{won(a.balance)}</td>
|
||||
<td style={cell}><button onClick={() => setInst({ code: a.code, name: a.name, balance: a.balance })} style={{ fontSize: 11 }}>편집</button> <button onClick={() => delInst(a.code)} style={{ fontSize: 11, color: '#c00' }}>삭제</button></td>
|
||||
</tr>))}</tbody>
|
||||
</table>
|
||||
<MiniTable title="처리상태 코드(데이터 사전)" cols={[['code', '코드'], ['name', '한글명'], ['desc', '설명']]} rows={codes} />
|
||||
</section>
|
||||
|
||||
{/* 사용자 권한 관리 */}
|
||||
<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 }}>
|
||||
<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>
|
||||
<button onClick={saveUser} style={{ padding: '8px 16px' }}>추가/수정</button>
|
||||
</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead><tr><th style={th}>사용자ID</th><th style={th}>사용자명</th><th style={th}>역할</th><th style={th}>매핑기관</th><th style={th}></th></tr></thead>
|
||||
<tbody>{users.map((u) => (
|
||||
<tr key={u.username}>
|
||||
<td style={{ ...cell, fontFamily: 'monospace' }}>{u.username}</td><td style={cell}>{u.displayName}</td>
|
||||
<td style={cell}><b>{u.role}</b></td><td style={{ ...cell, fontFamily: 'monospace' }}>{u.orgCode ?? '-'}</td>
|
||||
<td style={cell}><button onClick={() => delUser(u.username)} style={{ fontSize: 11, color: '#c00' }}>삭제</button></td>
|
||||
</tr>))}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* 테스트 실행 (B6) */}
|
||||
<section style={box}>
|
||||
<h2 style={{ marginTop: 0 }}>🧪 테스트 실행 — 이체 대량 생성</h2>
|
||||
<p style={{ color: '#555' }}>송신/수신기관 <b>랜덤</b>, 금액 <b>1,000~1,000,000원 랜덤</b>, BMI는 시작번호부터 순번 증가.
|
||||
(Chanel 직접 호출 — 관문/인증 우회. k6 대체용 간이 도구)</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
|
||||
<label>BMI 시작번호<br /><input value={test.bmiStart} onChange={(e) => setTest({ ...test, bmiStart: e.target.value })} style={{ padding: 6, width: 220, fontFamily: 'monospace' }} /></label>
|
||||
<label>건수<br /><input type="number" value={test.count} onChange={(e) => setTest({ ...test, count: Number(e.target.value) })} style={{ padding: 6, width: 100 }} /></label>
|
||||
<button onClick={runTest} disabled={testRun?.running} style={{ padding: '8px 16px', background: '#2d6cdf', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}>{testRun?.running ? '실행 중…' : '실행'}</button>
|
||||
</div>
|
||||
{testRun && (
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||
진행 <b>{testRun.done}/{testRun.sent}</b> · 접수(RCVD) {testRun.rcvd} · 반려(RJCT) {testRun.rjct} · 오류 {testRun.err}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{msg && <p style={{ color: '#1a7f37', marginTop: 12 }}>{msg}</p>}
|
||||
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
frontend/src/App.tsx
Normal file
257
frontend/src/App.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import AdminPanel from './AdminPanel'
|
||||
|
||||
type Account = { code: string; name: string; balance: number }
|
||||
type Inquiry = Record<string, unknown>
|
||||
|
||||
const won = (n: number) => n.toLocaleString('ko-KR') + '원'
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
function genBmi(sender: string): string {
|
||||
const d = new Date()
|
||||
const date = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
|
||||
const serial = String(Date.now() % 10_000_000_000).padStart(10, '0')
|
||||
return date + sender.padStart(4, '0') + serial
|
||||
}
|
||||
|
||||
// epoch(ms) -> YYYYMMDDHH24MISS (14자리)
|
||||
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 STATUS_KO: Record<string, string> = {
|
||||
RCVD: '접수', ACTC: '승인', PDNG: '대기', ACSP: '예약', ACCC: '입금처리완료', RJCT: '반려', IN_FLIGHT: '처리중',
|
||||
}
|
||||
const TIME_FIELDS = new Set(['updatedAt', 'createdAt'])
|
||||
const statusColor = (s?: string) =>
|
||||
s === 'ACCC' ? '#1a7f37' : s === 'RJCT' ? '#c00' : s ? '#8a6d00' : '#555'
|
||||
|
||||
function prettyXml(xml: string): string {
|
||||
return xml.replace(/></g, '>\n<')
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [labels, setLabels] = useState<Record<string, string>>({})
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
// 자금이체 신청
|
||||
const [from, setFrom] = useState('1001')
|
||||
const [to, setTo] = useState('1002')
|
||||
const [amount, setAmount] = useState(150)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [result, setResult] = useState<null | {
|
||||
bmi: string; recv: string; recvReason?: string; final?: string; finalReason?: string | null; pending?: boolean
|
||||
}>(null)
|
||||
|
||||
// 거래 조회
|
||||
const [bmi, setBmi] = useState('')
|
||||
const [inquiry, setInquiry] = useState<Inquiry | null>(null)
|
||||
const [rawXml, setRawXml] = useState<string | null>(null)
|
||||
|
||||
const [view, setView] = useState<'ops' | 'admin'>('ops')
|
||||
|
||||
const loadAccounts = async () => {
|
||||
setErr(null)
|
||||
try {
|
||||
const r = await fetch('/accounts')
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||||
setAccounts(await r.json())
|
||||
} catch (e: any) {
|
||||
setErr('계좌 조회 실패: ' + e.message + ' (Chanel :8091 기동 확인)')
|
||||
}
|
||||
}
|
||||
|
||||
const loadLabels = async () => {
|
||||
try { setLabels(await (await fetch('/meta/labels')).json()) } catch { /* 라벨 없으면 영문키 표시 */ }
|
||||
}
|
||||
|
||||
const submitTransfer = async () => {
|
||||
setErr(null); setResult(null)
|
||||
if (from === to) { setErr('송신기관과 수신기관이 같을 수 없습니다.'); return }
|
||||
if (!amount || amount <= 0) { setErr('이체금액은 1원 이상이어야 합니다.'); return }
|
||||
setBusy(true)
|
||||
const newBmi = genBmi(from)
|
||||
try {
|
||||
const xml =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08"><FIToFICstmrCdtTrf>` +
|
||||
`<GrpHdr><MsgId>${newBmi}</MsgId><CreDtTm>${new Date().toISOString().replace(/\.\d+Z$/, '')}</CreDtTm>` +
|
||||
`<NbOfTxs>1</NbOfTxs><SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr>` +
|
||||
`<CdtTrfTxInf><PmtId><EndToEndId>${newBmi}</EndToEndId></PmtId>` +
|
||||
`<IntrBkSttlmAmt Ccy="KRW">${amount}</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>`
|
||||
const res = await fetch('/pay/customer', { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: xml })
|
||||
const doc = new DOMParser().parseFromString(await res.text(), 'application/xml')
|
||||
const recv = doc.querySelector('TxSts')?.textContent ?? '?'
|
||||
const clrRef = doc.querySelector('ClrSysRef')?.textContent ?? ''
|
||||
if (recv !== 'RCVD') { setResult({ bmi: newBmi, recv, recvReason: clrRef }); setBusy(false); return }
|
||||
setResult({ bmi: newBmi, recv, pending: true })
|
||||
let final: any = null
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await sleep(700)
|
||||
const j = await (await fetch('/inquiry/' + newBmi)).json()
|
||||
if (j.status === 'ACCC' || j.status === 'RJCT') { final = j; break }
|
||||
}
|
||||
setResult({ bmi: newBmi, recv, final: final?.status ?? '처리중', finalReason: final?.reason, pending: !final })
|
||||
await loadAccounts()
|
||||
setBmi(newBmi) // 조회창에 자동 채움
|
||||
} catch (e: any) {
|
||||
setErr('이체 신청 실패: ' + e.message)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const doInquiry = async () => {
|
||||
setErr(null); setInquiry(null); setRawXml(null)
|
||||
const key = bmi.trim()
|
||||
if (!key) return
|
||||
try {
|
||||
const [inq, raw] = await Promise.all([
|
||||
fetch('/inquiry/' + encodeURIComponent(key)).then((r) => r.json()),
|
||||
fetch('/rawmessage/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => null),
|
||||
])
|
||||
setInquiry(inq)
|
||||
setRawXml(raw?.found ? raw.rawXml : null)
|
||||
} catch (e: any) {
|
||||
setErr('거래 조회 실패: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadAccounts(); loadLabels() }, [])
|
||||
|
||||
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' }
|
||||
|
||||
const renderVal = (k: string, v: unknown) => {
|
||||
if (k === 'status') {
|
||||
const s = String(v)
|
||||
return <b style={{ color: statusColor(s) }}>{s}{STATUS_KO[s] ? ` (${STATUS_KO[s]})` : ''}</b>
|
||||
}
|
||||
if (TIME_FIELDS.has(k)) return <span style={{ fontFamily: 'monospace' }}>{fmtTs(v)}</span>
|
||||
if (k === 'amount' && typeof v === 'number') return won(v)
|
||||
return String(v ?? '-')
|
||||
}
|
||||
|
||||
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={{ display: 'flex', gap: 8, margin: '12px 0' }}>
|
||||
<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 === 'admin' && <AdminPanel />}
|
||||
|
||||
{view === 'ops' && (<>
|
||||
{/* 자금이체 신청 */}
|
||||
<section style={{ ...box, background: '#f7fbff' }}>
|
||||
<h2 style={{ marginTop: 0 }}>💸 자금이체 신청 (pacs.008)</h2>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label>송신기관(ORG_S)<br />
|
||||
<select value={from} onChange={(e) => setFrom(e.target.value)} style={{ padding: 8, minWidth: 200 }}>
|
||||
{accounts.map((a) => <option key={a.code} value={a.code}>{a.code} {a.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>수신기관(ORG_R)<br />
|
||||
<select value={to} onChange={(e) => setTo(e.target.value)} style={{ padding: 8, minWidth: 200 }}>
|
||||
{accounts.map((a) => <option key={a.code} value={a.code}>{a.code} {a.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>이체금액(원)<br />
|
||||
<input type="number" min={1} value={amount} onChange={(e) => setAmount(Number(e.target.value))} style={{ padding: 8, width: 140 }} />
|
||||
</label>
|
||||
<button onClick={submitTransfer} disabled={busy}
|
||||
style={{ padding: '10px 20px', background: busy ? '#9ac' : '#0b63c4', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}>
|
||||
{busy ? '처리중…' : '이체 신청'}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<div style={{ marginTop: 14, padding: 12, background: '#fff', border: '1px solid #ddd', borderRadius: 6 }}>
|
||||
<div>거래식별자(BMI): <code>{result.bmi}</code></div>
|
||||
<div>접수응답: <b style={{ color: statusColor(result.recv) }}>{result.recv}{STATUS_KO[result.recv] ? ` (${STATUS_KO[result.recv]})` : ''}</b>
|
||||
{result.recvReason && <span style={{ color: '#c00' }}> — {result.recvReason}</span>}</div>
|
||||
{result.recv === 'RCVD' && (
|
||||
<div>최종상태: {result.pending && !result.final
|
||||
? <span style={{ color: '#8a6d00' }}>처리중…</span>
|
||||
: <b style={{ color: statusColor(result.final) }}>{result.final}{STATUS_KO[result.final ?? ''] ? ` (${STATUS_KO[result.final!]})` : ''}</b>}
|
||||
{result.finalReason && <span style={{ color: '#c00' }}> — {result.finalReason}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 거래 조회 */}
|
||||
<section style={box}>
|
||||
<h2 style={{ marginTop: 0 }}>🔎 거래 상태 조회</h2>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input value={bmi} onChange={(e) => setBmi(e.target.value)} placeholder="BMI (22자리 거래식별자)"
|
||||
style={{ flex: 1, padding: 8, fontFamily: 'monospace' }} />
|
||||
<button onClick={doInquiry} style={{ padding: '8px 16px' }}>조회</button>
|
||||
</div>
|
||||
|
||||
{inquiry && (
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', marginTop: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
|
||||
<th style={{ ...cell, width: 200 }}>항목(한글)</th><th style={cell}>영문 필드</th><th style={cell}>값</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(inquiry).filter(([k]) => k !== 'note').map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td style={{ ...cell, fontWeight: 600 }}>{labels[k] ?? k}</td>
|
||||
<td style={{ ...cell, color: '#888', fontFamily: 'monospace' }}>{k}</td>
|
||||
<td style={cell}>{renderVal(k, v)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</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 && rawXml === null && <p style={{ color: '#888', marginTop: 8 }}>(원문 전문 없음 — 접수 단계에서 반려되었거나 미저장)</p>}
|
||||
</section>
|
||||
|
||||
{/* 계좌 잔액 */}
|
||||
<section style={box}>
|
||||
<h2 style={{ marginTop: 0 }}>🏦 참가기관 당좌계좌 잔액 <button onClick={loadAccounts} style={{ fontSize: 12 }}>새로고침</button></h2>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
|
||||
<th style={cell}>코드</th><th style={cell}>기관</th><th style={{ ...cell, textAlign: 'right' }}>잔액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accounts.map((a) => (
|
||||
<tr key={a.code}>
|
||||
<td style={{ ...cell, fontFamily: 'monospace' }}>{a.code}</td>
|
||||
<td style={cell}>{a.name}</td>
|
||||
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{won(a.balance)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
</>)}
|
||||
|
||||
{err && <p style={{ color: '#c00', marginTop: 16 }}>{err}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
20
frontend/vite.config.ts
Normal file
20
frontend/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// RTGS 조회 콘솔 (:5174). Chanel(:8091)로 프록시하여 CORS 회피.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/accounts': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/inquiry': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/pay': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/meta': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/rawmessage': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/ledger': { target: 'http://localhost:8091', changeOrigin: true },
|
||||
'/admin': { target: 'http://localhost:8099', changeOrigin: true },
|
||||
'/gucci': { target: 'http://localhost:8095', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user