feat(3c): DT 단말 + 콘솔 센터(DC1/DC2/DC3) 전환

- DT 단말(:5180, dt.html/src/dt): 접속 센터 선택 후 해당 Gucci 관문으로 로그인·이체·조회.
  vite.dt.config 프록시 /dc1|/dc2|/dc3 -> 8095|8195|8295. X-Timestamp 초 단위(ReplayGuard).
- 콘솔 센터 전환: auth.ts getDc/setDc + X-RTGS-DC 헤더, App.tsx 드롭다운(+reload),
  vite.config router로 헤더 기반 포트 라우팅(+0/+100/+200) → 3센터 원장·대사·보고서 전환 조회.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rtgs
2026-07-16 17:33:43 +09:00
parent d180d516cd
commit 427f361783
8 changed files with 269 additions and 26 deletions

12
frontend/dt.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 단말 (DT)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/dt/main.tsx"></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:dt": "vite --config vite.dt.config.ts",
"build": "tsc && vite build",
"preview": "vite preview"
},

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import AdminPanel from './AdminPanel'
import Portal from './Portal'
import Report from './Report'
import { getDc, setDc, dcHeaders } from './auth'
type Account = { code: string; name: string; balance: number }
type Inquiry = Record<string, unknown>
@@ -71,7 +72,7 @@ export default function App() {
const loadAccounts = async () => {
setErr(null)
try {
const r = await fetch('/accounts')
const r = await fetch('/accounts', { headers: dcHeaders() })
if (!r.ok) throw new Error('HTTP ' + r.status)
setAccounts(await r.json())
} catch (e: any) {
@@ -80,7 +81,7 @@ export default function App() {
}
const loadLabels = async () => {
try { setLabels(await (await fetch('/meta/labels')).json()) } catch { /* 라벨 없으면 영문키 표시 */ }
try { setLabels(await (await fetch('/meta/labels', { headers: dcHeaders() })).json()) } catch { /* 라벨 없으면 영문키 표시 */ }
}
const submitTransfer = async () => {
@@ -102,7 +103,7 @@ export default function App() {
`<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 res = await fetch('/pay/customer', { method: 'POST', headers: dcHeaders({ '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 ?? ''
@@ -111,7 +112,7 @@ export default function App() {
let final: any = null
for (let i = 0; i < 8; i++) {
await sleep(700)
const j = await (await fetch('/inquiry/' + newBmi)).json()
const j = await (await fetch('/inquiry/' + newBmi, { headers: dcHeaders() })).json()
if (j.status === 'ACCC' || j.status === 'RJCT') { final = j; break }
}
setResult({ bmi: newBmi, recv, final: final?.status ?? '처리중', finalReason: final?.reason, pending: !final })
@@ -128,9 +129,9 @@ export default function App() {
if (!key) return
try {
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(() => []),
fetch('/inquiry/' + encodeURIComponent(key), { headers: dcHeaders() }).then((r) => r.json()),
fetch('/rawmessage/' + encodeURIComponent(key), { headers: dcHeaders() }).then((r) => r.json()).catch(() => null),
fetch('/notifications/' + encodeURIComponent(key), { headers: dcHeaders() }).then((r) => r.json()).catch(() => []),
])
setInquiry(inq)
setRawXml(raw?.found ? raw.rawXml : null)
@@ -159,12 +160,20 @@ export default function App() {
return (
<div style={{ fontFamily: 'system-ui, sans-serif', margin: '1.5rem', padding: '0 0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<h1 style={{ margin: 0 }}>RTGS <span style={{ fontSize: 14, color: '#888' }}>(DC1 · :5174)</span></h1>
<h1 style={{ margin: 0 }}>RTGS <span style={{ fontSize: 14, color: '#888' }}>({getDc()} · :5174)</span></h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<label style={{ fontSize: 14, color: '#444' }}> &nbsp;
<select value={getDc()} onChange={(e) => { setDc(e.target.value); window.location.reload() }}
style={{ padding: '7px 10px', borderRadius: 6, border: '1px solid #ccc', fontWeight: 700 }}>
<option value="DC1">DC1</option><option value="DC2">DC2</option><option value="DC3">DC3</option>
</select>
</label>
<a href="/manual.html" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', border: '1px solid #ccc', borderRadius: 6, background: '#fff', color: '#333', textDecoration: 'none', fontSize: 14, fontWeight: 600 }}>
📖
</a>
</div>
</div>
<div style={{ display: 'flex', gap: 8, margin: '12px 0' }}>
<button onClick={() => setView('portal')}

View File

@@ -4,6 +4,15 @@ import { useEffect, useState } from 'react'
const TOKEN_KEY = 'rtgs_admin_token'
const USER_KEY = 'rtgs_admin_user'
const DC_KEY = 'rtgs_dc'
/** 콘솔이 조회할 대상 센터(DC1/DC2/DC3). vite 프록시가 X-RTGS-DC 헤더로 포트를 라우팅한다. */
export const getDc = () => localStorage.getItem(DC_KEY) || 'DC1'
export const setDc = (dc: string) => localStorage.setItem(DC_KEY, dc)
/** fetch 옵션에 센터 헤더를 합친다(모든 백엔드 호출 공용). */
export function dcHeaders(h: Record<string, string> = {}): Record<string, string> {
return { ...h, 'X-RTGS-DC': getDc() }
}
type Listener = () => void
const listeners = new Set<Listener>()
@@ -19,7 +28,7 @@ export function clearToken() {
}
export function authHeader(json = false): Record<string, string> {
const h: Record<string, string> = {}
const h: Record<string, string> = { 'X-RTGS-DC': getDc() }
if (json) h['Content-Type'] = 'application/json'
const t = getToken(); if (t) h['Authorization'] = `Bearer ${t}`
return h
@@ -44,7 +53,7 @@ export async function jsend(url: string, body?: any, method = 'POST'): Promise<a
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 }),
method: 'POST', headers: dcHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ username, secret }),
})
if (!resp.ok) return { ok: false, error: '로그인 실패 — 아이디/비밀번호를 확인하세요' }
const r = await resp.json()
@@ -57,7 +66,7 @@ export async function login(username: string, secret: string): Promise<{ ok: boo
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 }),
method: 'POST', headers: dcHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ username, oldSecret, newSecret }),
})
if (!resp.ok) return { ok: false, error: '비밀번호 변경 실패' }
return { ok: true }

174
frontend/src/dt/DtApp.tsx Normal file
View File

@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from 'react'
import { buildPacs008, won } from '../util'
// RTGS 참가기관 단말(DT). 접속 센터(DC1/DC2/DC3)를 골라 그 센터의 Gucci 관문으로
// 로그인·이체·조회한다. 센터 전환 = API prefix(/dcN) 변경. 장애 시 다른 센터로 전환해 계속 이용.
type Account = { code: string; name: string; balance: number }
const CENTERS = [
{ id: 'dc1', name: 'DC1', gucci: 8095 },
{ id: 'dc2', name: 'DC2', gucci: 8195 },
{ id: 'dc3', name: 'DC3', gucci: 8295 },
]
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const rand = (n: number) => Math.random().toString(36).slice(2, 2 + n)
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
}
export default function DtApp() {
const [dc, setDc] = useState('dc1')
const base = `/${dc}`
const center = CENTERS.find((c) => c.id === dc)!
const [token, setToken] = useState<string | null>(null)
const [user] = useState('a')
const [secret, setSecret] = useState('1')
const [health, setHealth] = useState<any>(null)
const [accounts, setAccounts] = useState<Account[]>([])
const [from, setFrom] = useState('1001')
const [to, setTo] = useState('1002')
const [amount, setAmount] = useState(50000)
const [busy, setBusy] = useState(false)
const [log, setLog] = useState<{ t: string; msg: string; kind?: string }[]>([])
const [err, setErr] = useState<string | null>(null)
const bottom = useRef<HTMLDivElement>(null)
const addLog = (msg: string, kind?: string) =>
setLog((l) => [...l, { t: new Date().toLocaleTimeString('ko-KR'), msg, kind }])
useEffect(() => { bottom.current?.scrollIntoView({ behavior: 'smooth' }) }, [log])
// 센터 전환 시 세션 초기화(센터별 로그인)
useEffect(() => { setToken(null); setAccounts([]); setErr(null); loadHealth() /* eslint-disable-next-line */ }, [dc])
const loadHealth = async () => {
try { setHealth(await (await fetch(`${base}/gucci/health/centers`)).json()) }
catch { setHealth(null) }
}
const login = async () => {
setErr(null)
try {
const r = await fetch(`${base}/gucci/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, secret }),
})
if (!r.ok) { setErr('로그인 실패 — 자격증명/센터 상태 확인'); return }
const j = await r.json()
setToken(j.token)
addLog(`[${center.name}] 로그인 성공 (role ${j.role})`, 'ok')
await loadAccounts(j.token)
} catch (e: any) { setErr('로그인 오류: ' + e.message) }
}
const loadAccounts = async (tok = token) => {
try {
const r = await fetch(`${base}/gucci/accounts`, { headers: { Authorization: `Bearer ${tok}` } })
if (r.ok) setAccounts(await r.json())
} catch { /* ignore */ }
}
const submit = async () => {
if (!token) { setErr('먼저 로그인하세요'); return }
if (from === to) { setErr('송신/수신 기관이 같을 수 없습니다'); return }
setErr(null); setBusy(true)
const bmi = genBmi(from)
addLog(`[${center.name}] 이체 신청 ${from}${to} ${won(amount)} (BMI ${bmi})`)
try {
const res = await fetch(`${base}/gucci/pay/customer`, {
method: 'POST',
headers: {
'Content-Type': 'application/xml',
Authorization: `Bearer ${token}`,
'X-Nonce': `${bmi}-${rand(6)}`,
'X-Timestamp': String(Math.floor(Date.now() / 1000)), // Gucci ReplayGuard는 초 단위 기대
},
body: buildPacs008(bmi, from, to, amount),
})
const txt = await res.text()
if (!res.ok) { addLog(`관문 거부(HTTP ${res.status}): ${txt.slice(0, 120)}`, 'err'); setBusy(false); return }
const recv = new DOMParser().parseFromString(txt, 'application/xml').querySelector('TxSts')?.textContent ?? '?'
addLog(`접수 응답: ${recv}`, recv === 'RCVD' ? 'ok' : 'err')
if (recv !== 'RCVD') { setBusy(false); return }
// 완결까지 폴링
let final = '처리중'
for (let i = 0; i < 10; i++) {
await sleep(700)
const j = await (await fetch(`${base}/gucci/inquiry/${bmi}`, { headers: { Authorization: `Bearer ${token}` } })).json()
if (j.status === 'ACCC' || j.status === 'RJCT') { final = j.status; break }
}
addLog(`최종 상태: ${final}`, final === 'ACCC' ? 'ok' : 'err')
await loadAccounts()
} catch (e: any) { addLog('이체 오류: ' + e.message, 'err') }
finally { setBusy(false) }
}
const coreUp = health?.centers?.[0]?.['core(chanel)'] === 'UP'
const inp: React.CSSProperties = { padding: 8, borderRadius: 6, border: '1px solid #ccc' }
const bankOpt = (a: Account) => <option key={a.code} value={a.code}>{a.code} {a.name}</option>
return (
<div style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 720, margin: '1.5rem auto', padding: '0 1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<h1 style={{ margin: 0, fontSize: 22 }}>🏧 RTGS <span style={{ fontSize: 13, color: '#888' }}>(DT · )</span></h1>
<label style={{ fontSize: 14 }}> &nbsp;
<select value={dc} onChange={(e) => setDc(e.target.value)} style={{ ...inp, fontWeight: 700 }}>
{CENTERS.map((c) => <option key={c.id} value={c.id}>{c.name} (Gucci :{c.gucci})</option>)}
</select>
</label>
</div>
<div style={{ marginTop: 12, padding: '10px 14px', borderRadius: 8, background: coreUp ? '#e6f4ea' : '#fde8e8', border: `1px solid ${coreUp ? '#8bd3a0' : '#f0a0a0'}` }}>
<b>{center.name}</b> {coreUp ? '🟢 정상' : '🔴 접속 불가'} {token ? '· 🔓 로그인됨' : '· 🔒 미로그인'}
{!coreUp && <span style={{ color: '#c00' }}> ( )</span>}
</div>
{/* 로그인 */}
{!token && (
<section style={{ marginTop: 16, padding: 16, border: '1px solid #e2e2e2', borderRadius: 8 }}>
<h3 style={{ marginTop: 0 }}>🔑 </h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input value={user} readOnly style={{ ...inp, width: 90, background: '#f5f5f5' }} />
<input value={secret} onChange={(e) => setSecret(e.target.value)} type="password" placeholder="비밀번호" style={{ ...inp, width: 120 }} />
<button onClick={login} style={{ ...inp, background: '#0b63c4', color: '#fff', border: 0, cursor: 'pointer', fontWeight: 600 }}></button>
<span style={{ color: '#999', fontSize: 12 }}> a / 1</span>
</div>
</section>
)}
{/* 이체 */}
{token && (
<section style={{ marginTop: 16, padding: 16, border: '1px solid #e2e2e2', borderRadius: 8, background: '#f7fbff' }}>
<h3 style={{ marginTop: 0 }}>💸 <span style={{ fontSize: 12, color: '#888' }}>({center.name} )</span></h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<label><br /><select value={from} onChange={(e) => setFrom(e.target.value)} style={{ ...inp, minWidth: 160 }}>{accounts.map(bankOpt)}</select></label>
<label><br /><select value={to} onChange={(e) => setTo(e.target.value)} style={{ ...inp, minWidth: 160 }}>{accounts.map(bankOpt)}</select></label>
<label>()<br /><input type="number" min={1} value={amount} onChange={(e) => setAmount(Number(e.target.value))} style={{ ...inp, width: 130 }} /></label>
<button onClick={submit} disabled={busy} style={{ ...inp, background: busy ? '#9ac' : '#0b63c4', color: '#fff', border: 0, cursor: 'pointer', fontWeight: 600 }}>{busy ? '처리중…' : '이체 신청'}</button>
</div>
</section>
)}
{err && <p style={{ color: '#c00' }}>{err}</p>}
{/* 처리 로그 */}
<section style={{ marginTop: 16 }}>
<h3 style={{ marginBottom: 6 }}>📜 </h3>
<div style={{ background: '#0d1117', color: '#c9d1d9', borderRadius: 8, padding: 12, height: 260, overflow: 'auto', fontSize: 13, fontFamily: 'monospace' }}>
{log.length === 0 && <div style={{ color: '#666' }}>( )</div>}
{log.map((l, i) => (
<div key={i} style={{ color: l.kind === 'ok' ? '#3fb950' : l.kind === 'err' ? '#f85149' : '#c9d1d9' }}>
<span style={{ color: '#6e7681' }}>{l.t}</span> {l.msg}
</div>
))}
<div ref={bottom} />
</div>
</section>
</div>
)
}

9
frontend/src/dt/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import DtApp from './DtApp'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<DtApp />
</React.StrictMode>,
)

View File

@@ -1,22 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// RTGS 조회 콘솔 (:5174). Chanel(:8091)로 프록시하여 CORS 회피.
// RTGS 조회/관리 콘솔 (:5174). 요청 헤더 X-RTGS-DC(DC1/DC2/DC3)로 대상 센터 포트를 라우팅한다.
// Chanel계열 8091 / LouisVuitton(admin) 8099 / Gucci 8095, 센터별 +100/+200 오프셋.
const OFFSET: Record<string, number> = { DC1: 0, DC2: 100, DC3: 200 }
const off = (req: any) => OFFSET[(req.headers?.['x-rtgs-dc'] as string) || 'DC1'] ?? 0
const routed = (basePort: number) => ({
target: `http://localhost:${basePort}`,
changeOrigin: true,
router: (req: any) => `http://localhost:${basePort + off(req)}`,
})
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 },
'/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 },
'/accounts': routed(8091),
'/inquiry': routed(8091),
'/pay': routed(8091),
'/meta': routed(8091),
'/rawmessage': routed(8091),
'/notifications': routed(8091),
'/ledger': routed(8091),
'/admin': routed(8099),
'/console': routed(8099),
'/gucci': routed(8095),
},
},
})

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// RTGS 단말(DT, :5180). 참가기관 단말 — 각 센터 Gucci 관문으로 프록시.
// /dc1 -> DC1 Gucci :8095, /dc2 -> DC2 :8195, /dc3 -> DC3 :8295 (prefix 제거).
// 단말은 접속 센터를 골라 /dcN/gucci/... 로 호출한다(센터 전환 = prefix 변경).
const CENTERS: Record<string, number> = { dc1: 8095, dc2: 8195, dc3: 8295 }
const proxy: Record<string, any> = {}
for (const [dc, port] of Object.entries(CENTERS)) {
proxy[`/${dc}/`] = {
target: `http://localhost:${port}`,
changeOrigin: true,
rewrite: (p: string) => p.replace(new RegExp(`^/${dc}`), ''),
}
}
export default defineConfig({
plugins: [react()],
server: { port: 5180, open: '/dt.html', proxy },
})