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:
12
frontend/dt.html
Normal file
12
frontend/dt.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 단말 (DT)</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/dt/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:dt": "vite --config vite.dt.config.ts",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import AdminPanel from './AdminPanel'
|
import AdminPanel from './AdminPanel'
|
||||||
import Portal from './Portal'
|
import Portal from './Portal'
|
||||||
import Report from './Report'
|
import Report from './Report'
|
||||||
|
import { getDc, setDc, dcHeaders } from './auth'
|
||||||
|
|
||||||
type Account = { code: string; name: string; balance: number }
|
type Account = { code: string; name: string; balance: number }
|
||||||
type Inquiry = Record<string, unknown>
|
type Inquiry = Record<string, unknown>
|
||||||
@@ -71,7 +72,7 @@ export default function App() {
|
|||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/accounts')
|
const r = await fetch('/accounts', { headers: dcHeaders() })
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||||||
setAccounts(await r.json())
|
setAccounts(await r.json())
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -80,7 +81,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadLabels = async () => {
|
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 () => {
|
const submitTransfer = async () => {
|
||||||
@@ -102,7 +103,7 @@ export default function App() {
|
|||||||
`<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>${to}</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>` +
|
`<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>` +
|
`<Cdtr><Nm>BANK-${to}</Nm></Cdtr><CdtrAcct><Id><Othr><Id>ACC-${to}</Id></Othr></Id></CdtrAcct>` +
|
||||||
`</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>`
|
`</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 doc = new DOMParser().parseFromString(await res.text(), 'application/xml')
|
||||||
const recv = doc.querySelector('TxSts')?.textContent ?? '?'
|
const recv = doc.querySelector('TxSts')?.textContent ?? '?'
|
||||||
const clrRef = doc.querySelector('ClrSysRef')?.textContent ?? ''
|
const clrRef = doc.querySelector('ClrSysRef')?.textContent ?? ''
|
||||||
@@ -111,7 +112,7 @@ export default function App() {
|
|||||||
let final: any = null
|
let final: any = null
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
await sleep(700)
|
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 }
|
if (j.status === 'ACCC' || j.status === 'RJCT') { final = j; break }
|
||||||
}
|
}
|
||||||
setResult({ bmi: newBmi, recv, final: final?.status ?? '처리중', finalReason: final?.reason, pending: !final })
|
setResult({ bmi: newBmi, recv, final: final?.status ?? '처리중', finalReason: final?.reason, pending: !final })
|
||||||
@@ -128,9 +129,9 @@ export default function App() {
|
|||||||
if (!key) return
|
if (!key) return
|
||||||
try {
|
try {
|
||||||
const [inq, raw, nts] = await Promise.all([
|
const [inq, raw, nts] = await Promise.all([
|
||||||
fetch('/inquiry/' + encodeURIComponent(key)).then((r) => r.json()),
|
fetch('/inquiry/' + encodeURIComponent(key), { headers: dcHeaders() }).then((r) => r.json()),
|
||||||
fetch('/rawmessage/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => null),
|
fetch('/rawmessage/' + encodeURIComponent(key), { headers: dcHeaders() }).then((r) => r.json()).catch(() => null),
|
||||||
fetch('/notifications/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => []),
|
fetch('/notifications/' + encodeURIComponent(key), { headers: dcHeaders() }).then((r) => r.json()).catch(() => []),
|
||||||
])
|
])
|
||||||
setInquiry(inq)
|
setInquiry(inq)
|
||||||
setRawXml(raw?.found ? raw.rawXml : null)
|
setRawXml(raw?.found ? raw.rawXml : null)
|
||||||
@@ -159,12 +160,20 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif', margin: '1.5rem', padding: '0 0.5rem' }}>
|
<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 }}>
|
<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' }}>모니터링 센터
|
||||||
|
<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"
|
<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 }}>
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, margin: '12px 0' }}>
|
<div style={{ display: 'flex', gap: 8, margin: '12px 0' }}>
|
||||||
<button onClick={() => setView('portal')}
|
<button onClick={() => setView('portal')}
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import { useEffect, useState } from 'react'
|
|||||||
|
|
||||||
const TOKEN_KEY = 'rtgs_admin_token'
|
const TOKEN_KEY = 'rtgs_admin_token'
|
||||||
const USER_KEY = 'rtgs_admin_user'
|
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
|
type Listener = () => void
|
||||||
const listeners = new Set<Listener>()
|
const listeners = new Set<Listener>()
|
||||||
@@ -19,7 +28,7 @@ export function clearToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function authHeader(json = false): Record<string, string> {
|
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'
|
if (json) h['Content-Type'] = 'application/json'
|
||||||
const t = getToken(); if (t) h['Authorization'] = `Bearer ${t}`
|
const t = getToken(); if (t) h['Authorization'] = `Bearer ${t}`
|
||||||
return h
|
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 }> {
|
export async function login(username: string, secret: string): Promise<{ ok: boolean; mustChange?: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/gucci/auth/login', {
|
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: '로그인 실패 — 아이디/비밀번호를 확인하세요' }
|
if (!resp.ok) return { ok: false, error: '로그인 실패 — 아이디/비밀번호를 확인하세요' }
|
||||||
const r = await resp.json()
|
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 }> {
|
export async function changePassword(username: string, oldSecret: string, newSecret: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/gucci/auth/change-password', {
|
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: '비밀번호 변경 실패' }
|
if (!resp.ok) return { ok: false, error: '비밀번호 변경 실패' }
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
|||||||
174
frontend/src/dt/DtApp.tsx
Normal file
174
frontend/src/dt/DtApp.tsx
Normal 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 }}>접속 센터
|
||||||
|
<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
9
frontend/src/dt/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
@@ -1,22 +1,31 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5174,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/accounts': { target: 'http://localhost:8091', changeOrigin: true },
|
'/accounts': routed(8091),
|
||||||
'/inquiry': { target: 'http://localhost:8091', changeOrigin: true },
|
'/inquiry': routed(8091),
|
||||||
'/pay': { target: 'http://localhost:8091', changeOrigin: true },
|
'/pay': routed(8091),
|
||||||
'/meta': { target: 'http://localhost:8091', changeOrigin: true },
|
'/meta': routed(8091),
|
||||||
'/rawmessage': { target: 'http://localhost:8091', changeOrigin: true },
|
'/rawmessage': routed(8091),
|
||||||
'/notifications': { target: 'http://localhost:8091', changeOrigin: true },
|
'/notifications': routed(8091),
|
||||||
'/ledger': { target: 'http://localhost:8091', changeOrigin: true },
|
'/ledger': routed(8091),
|
||||||
'/admin': { target: 'http://localhost:8099', changeOrigin: true },
|
'/admin': routed(8099),
|
||||||
'/console': { target: 'http://localhost:8099', changeOrigin: true },
|
'/console': routed(8099),
|
||||||
'/gucci': { target: 'http://localhost:8095', changeOrigin: true },
|
'/gucci': routed(8095),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
20
frontend/vite.dt.config.ts
Normal file
20
frontend/vite.dt.config.ts
Normal 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 },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user