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:
rtgs
2026-07-15 15:37:32 +09:00
commit 58ca23b5d9
140 changed files with 11832 additions and 0 deletions

12
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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 },
},
},
})