diff --git a/frontend/dt.html b/frontend/dt.html new file mode 100644 index 0000000..4899d1c --- /dev/null +++ b/frontend/dt.html @@ -0,0 +1,12 @@ + + + + + + RTGS 단말 (DT) + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json index cb553d6..c609408 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:dt": "vite --config vite.dt.config.ts", "build": "tsc && vite build", "preview": "vite preview" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 193c709..6537758 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 @@ -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() { `${to}` + `BANK-${to}ACC-${to}` + `` - 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,11 +160,19 @@ export default function App() { return (
-

RTGS 콘솔 (DC1 · :5174)

- - 📖 서비스 매뉴얼 - +

RTGS 콘솔 ({getDc()} · :5174)

+
+ + + 📖 서비스 매뉴얼 + +
diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index 3691b4f..589b29d 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -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 = {}): Record { + return { ...h, 'X-RTGS-DC': getDc() } +} type Listener = () => void const listeners = new Set() @@ -19,7 +28,7 @@ export function clearToken() { } export function authHeader(json = false): Record { - const h: Record = {} + const h: Record = { '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 { 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 } diff --git a/frontend/src/dt/DtApp.tsx b/frontend/src/dt/DtApp.tsx new file mode 100644 index 0000000..6f9bcc5 --- /dev/null +++ b/frontend/src/dt/DtApp.tsx @@ -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(null) + const [user] = useState('a') + const [secret, setSecret] = useState('1') + const [health, setHealth] = useState(null) + const [accounts, setAccounts] = useState([]) + 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(null) + const bottom = useRef(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) => + + return ( +
+
+

🏧 RTGS 단말 (DT · 참가기관)

+ +
+ +
+ 이 단말은 {center.name} 센터를 이용 중 — 관문 {coreUp ? '🟢 정상' : '🔴 접속 불가'} {token ? '· 🔓 로그인됨' : '· 🔒 미로그인'} + {!coreUp && (다른 센터로 전환해 계속 이용 가능)} +
+ + {/* 로그인 */} + {!token && ( +
+

🔑 로그인

+
+ + setSecret(e.target.value)} type="password" placeholder="비밀번호" style={{ ...inp, width: 120 }} /> + + 테스트 계정 a / 1 +
+
+ )} + + {/* 이체 */} + {token && ( +
+

💸 자금이체 신청 ({center.name} 관문 경유)

+
+ + + + +
+
+ )} + + {err &&

{err}

} + + {/* 처리 로그 */} +
+

📜 단말 처리 로그

+
+ {log.length === 0 &&
(이체를 신청하면 처리 과정이 여기에 표시됩니다)
} + {log.map((l, i) => ( +
+ {l.t} {l.msg} +
+ ))} +
+
+
+
+ ) +} diff --git a/frontend/src/dt/main.tsx b/frontend/src/dt/main.tsx new file mode 100644 index 0000000..cd041b9 --- /dev/null +++ b/frontend/src/dt/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import DtApp from './DtApp' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9d133ca..7b46505 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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 = { 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), }, }, }) diff --git a/frontend/vite.dt.config.ts b/frontend/vite.dt.config.ts new file mode 100644 index 0000000..4015c7f --- /dev/null +++ b/frontend/vite.dt.config.ts @@ -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 = { dc1: 8095, dc2: 8195, dc3: 8295 } +const proxy: Record = {} +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 }, +})