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

View File

@@ -0,0 +1,29 @@
plugins {
kotlin("jvm")
kotlin("plugin.jpa") // all-open + no-arg for @Entity (Kotlin classes are final by default)
id("io.spring.dependency-management")
}
dependencyManagement {
imports {
mavenBom("org.springframework.boot:spring-boot-dependencies:3.3.4")
}
}
dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
// JPA annotations only (entities shared across ledger services); runtime provided by services.
compileOnly("jakarta.persistence:jakarta.persistence-api")
// 로그 -> Logstash(TCP json). 공유 logback-spring.xml과 함께 전 서비스에 전파(runtime).
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
implementation("io.micrometer:micrometer-registry-prometheus") // 메트릭(B4)
// 하트비트 자동설정(B5) 컴파일용 — 런타임은 각 서비스가 제공
compileOnly("org.springframework.boot:spring-boot-autoconfigure")
compileOnly("org.springframework:spring-context")
compileOnly("org.springframework:spring-jdbc")
compileOnly("org.slf4j:slf4j-api")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

View File

@@ -0,0 +1,37 @@
package kr.or.bok.rtgs.common
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.concurrent.atomic.AtomicLong
/**
* 거래고유식별자(BMI, Business Message Identifier) 생성기 — 총 22자리.
* 구성: 영업일(8, YYYYMMDD) + 기관코드(4) + 일련번호(10).
* BMI는 접수 센터에서 거래마다 유일하게 부여되며, 이후 저널을 통해 타 센터로 전파되어
* 멱등성(idempotency) 중복체크의 키가 된다(같은 거래는 두 번 반영되지 않음).
*/
class BmiGenerator(private val orgCode: String) {
private val serial = AtomicLong(0)
init {
require(orgCode.length <= 4) { "orgCode must be <= 4 chars: $orgCode" }
}
fun next(businessDate: LocalDate = LocalDate.now()): String {
val date = businessDate.format(DATE_FMT) // 8
val org = orgCode.padStart(4, '0') // 4
val seq = serial.incrementAndGet().toString().padStart(10, '0') // 10
return (date + org + seq).let {
require(it.length == 22) { "BMI length != 22: $it (${it.length})" }
it
}
}
companion object {
private val DATE_FMT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd")
/** BMI 구성요소 파싱(검증/조회용). */
fun businessDateOf(bmi: String): String = bmi.substring(0, 8)
fun orgCodeOf(bmi: String): String = bmi.substring(8, 12)
}
}

View File

@@ -0,0 +1,29 @@
package kr.or.bok.rtgs.common
/** Kafka 토픽 이름 — 입구/저널/결과. 전 센터 공유 단일 클러스터(순서 공유). */
object Topics {
const val INBOUND = "rtgs.inbound" // Chanel -> Sequencer (접수 원시 요청)
const val JOURNAL = "rtgs.journal" // Sequencer -> 전 센터 (전역순번 저널, pacs.008 계열)
const val RESULT = "rtgs.result" // Hermes -> Prada (결제결과, pacs.002 계열)
const val NOTIFY = "rtgs.notify" // Prada(완결) -> Chanel (결과통보 트리거, 접수센터가 신청/수취기관 앞 송부)
}
/** Hazelcast IMap 이름 — 인메모리 코어 연산(센터별 클러스터). */
object Maps {
const val ACCOUNTS = "accounts" // 참가기관 당좌계좌 잔액(코어 연산 캐시)
const val TRANSFERS = "transfers" // 거래 상태(BMI -> 상태/금액), 멱등 중복체크
}
/** PostgreSQL 원장 테이블 — 권위 저장소(강한 일관성). */
object Tables {
const val ACCOUNT = "account" // 참가기관 당좌계좌 권위 잔액
const val TRANSFER = "transfer" // 거래 원장(상태/금액/순번)
const val JOURNAL = "journal_log" // 선저널(write-ahead) 기록
}
object Msg {
// 정식 ISO20022 공식 XSD 버전과 일치(2026-07-10 전환).
const val PACS008 = "pacs.008.001.08"
const val PACS002 = "pacs.002.001.10"
const val BAH = "head.001.001.03"
}

View File

@@ -0,0 +1,35 @@
package kr.or.bok.rtgs.common
import java.security.MessageDigest
/**
* 경량 명령문(핵심 전문). 참가기관 원전문(pacs.008 XML, 수 KB)에서 원장 연산에 꼭 필요한
* 최소 정보만 추출한 수백 바이트 구조체. 센터 간에는 이것만 저널로 복제한다(복원력방안 2.7).
*
* - origHash: 원전문(XML) SHA-256 — 무결성·부인방지 지문. 원전문 자체는 별도 저장소(MongoDB)에 보관.
* - amount: 이체금액(원 단위 정수). 통화는 KRW 고정(프로토타입).
*/
data class CoreMessage(
val bmi: String, // 거래고유식별자 22자리 (Business Message Identifier)
val msgType: String, // 전문유형 (예: "pacs.008.001.11")
val senderCode: String, // 송신기관 코드 (ORG_S)
val receiverCode: String, // 수신기관 코드 (ORG_R)
val debtorAcct: String, // 의뢰인(참가기관 당좌계좌 관점에서는 송신기관 계좌) 식별
val creditorAcct: String, // 수취인 계좌 식별
val amount: Long, // 이체금액(원)
val currency: String = "KRW",
val origHash: String, // 원전문 SHA-256(hex)
) {
init {
require(amount > 0) { "amount must be positive: $amount" }
require(senderCode != receiverCode) { "sender and receiver must differ: $senderCode" }
}
companion object {
fun sha256Hex(raw: String): String {
val md = MessageDigest.getInstance("SHA-256")
val bytes = md.digest(raw.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
}
}

View File

@@ -0,0 +1,40 @@
package kr.or.bok.rtgs.common
/**
* 입구(rtgs.inbound) 봉투 — Chanel(접수센터) -> Sequencer.
* originCenter는 최초 접수 센터로, 저널을 통해 전 센터에 공유되어 결과통보 책임 센터를 정한다.
*/
data class InboundRequest(
val core: CoreMessage,
val originCenter: String,
)
/**
* 결과(rtgs.result) 봉투 — Hermes -> Prada.
* 결제처리 결과와 최종 상태(ACSP/RJCT)를 담아 결과동기화로 전달.
*/
data class ResultMessage(
val journalEntry: JournalEntry,
val status: TxSts,
val processedCenter: String,
val debtorBalanceAfter: Long? = null,
val creditorBalanceAfter: Long? = null,
val reason: String? = null,
)
/**
* 결과통보(rtgs.notify) 봉투 — Prada(완결) -> Chanel.
* 거래가 최종 상태(ACCC/RJCT)로 확정된 뒤, 접수센터(originCenter)의 Chanel이 신청기관(송신)·
* 수취기관 앞 결과 전문(pacs.002)을 생성·송부하도록 트리거한다.
*/
data class SettlementNotice(
val bmi: String,
val originCenter: String, // 접수센터 = 결과통보 책임 센터
val senderCode: String, // 신청(송신)기관
val receiverCode: String, // 수취기관
val amount: Long,
val globalSeq: Long,
val finalStatus: TxSts, // ACCC or RJCT
val reason: String? = null,
val finalizedAt: Long,
)

View File

@@ -0,0 +1,16 @@
package kr.or.bok.rtgs.common
/**
* 저널 엔트리 — 순번기(Sequencer)가 전역순번을 부여해 발행하는 명단의 한 줄.
* 3개 센터가 이 저널을 같은 순서(globalSeq)로 결정론적으로 재생한다.
*
* 결정론적 재생의 함정 대응(복원력방안 2.3-부속 전제③): 처리 로직에 필요한
* "센터마다 달라질 수 있는 값"(수신시각 등)은 순번기가 여기에 미리 박아 배포한다.
* 따라서 각 센터는 seqEpochMillis 등 저널에 실린 값만 사용하고, 자체 시각/난수를 쓰지 않는다.
*/
data class JournalEntry(
val globalSeq: Long, // 전역순번 (처리 순서의 권위)
val seqEpochMillis: Long, // 순번기가 확정한 결정론적 수신시각(epoch ms)
val originCenter: String, // 최초 접수 센터 (결과통보 책임 센터)
val core: CoreMessage,
)

View File

@@ -0,0 +1,26 @@
package kr.or.bok.rtgs.common
/**
* 정족수 컨텍스트 — 센터 구성과 과반(quorum) 판정.
* 복원력방안 2.1/2.3: 홀수 3센터라야 split-brain 차단. 완결 규율은 "과반 센터가 해당 순번을
* 확정한 시점"이 대외 효력을 갖는다.
*
* 로컬 1센터 개발 시 centerCount=1 → 과반=1(자기 자신)로 즉시 완결. N센터 확장 시 활성.
*/
data class QuorumContext(
val centerId: String, // 이 인스턴스의 센터 (DC1/DC2/DC3)
val centerCount: Int = 1, // 전체 근거리 동기 센터 수
) {
init {
require(centerCount >= 1) { "centerCount must be >= 1" }
}
/** 과반 임계값 = floor(N/2)+1. (1->1, 2->2, 3->2) */
val quorum: Int get() = centerCount / 2 + 1
/** 주어진 확정 센터 수가 과반을 충족하는가. */
fun hasQuorum(confirmations: Int): Boolean = confirmations >= quorum
/** 살아있는 센터 수로 서비스 지속 가능 여부(가용성): 과반 유지 시 지속. */
fun canServe(aliveCenters: Int): Boolean = aliveCenters >= quorum
}

View File

@@ -0,0 +1,26 @@
package kr.or.bok.rtgs.common
/**
* 거래 처리상태(ISO 20022 pacs.002 TxSts 계열).
* 흐름: RCVD -> ACTC -> (PDNG) -> ACSP -> ACCC, 실패 시 RJCT.
* - RCVD: 접수(Received) — Chanel이 인메모리에 최초 저장
* - ACTC: 승인(AcceptedTechnicalValidation) — 검증 통과, 순번기로 전달/저널 등록
* - PDNG: 대기(Pending) — 타 센터에서 수신되어 아직 결제 전
* - ACSP: 예약(AcceptedSettlementInProcess) — 결제처리(원장 반영) 완료, 결과 대기
* - ACCC: 입금처리완료(AcceptedSettlementCompleted) — 과반 확정, 최종 완결
* - RJCT: 반려(Rejected) — 검증 실패/잔액부족 등
*/
enum class TxSts {
RCVD,
ACTC,
PDNG,
ACSP,
ACCC,
RJCT,
;
companion object {
/** 완결(대외 효력) 상태 여부. */
fun isFinal(s: TxSts): Boolean = s == ACCC || s == RJCT
}
}

View File

@@ -0,0 +1,134 @@
package kr.or.bok.rtgs.common.iso20022
import kr.or.bok.rtgs.common.CoreMessage
import kr.or.bok.rtgs.common.Msg
import org.w3c.dom.Element
import org.w3c.dom.Node
import java.io.ByteArrayInputStream
import java.nio.charset.StandardCharsets
import javax.xml.parsers.DocumentBuilderFactory
/**
* ISO 20022 전문 XML <-> 객체 변환 + 핵심전문(CoreMessage) 추출.
*
* pacs.008.001.08은 네임스페이스가 있는 깊은 중첩 구조라 **DOM 파싱**으로 지역명(local name) 기준
* 탐색해 핵심 항목만 뽑는다(네임스페이스 프리픽스에 견고, XXE 차단). pacs.002.001.10 응답은
* **정식 네임스페이스 템플릿**으로 생성한다.
*/
object Iso20022Codec {
private const val PACS002_NS = "urn:iso:std:iso:20022:tech:xsd:pacs.002.001.10"
/** XXE 방어 + 네임스페이스 인식 DOM 파서 팩토리. */
private val dbf: DocumentBuilderFactory = DocumentBuilderFactory.newInstance().apply {
isNamespaceAware = true
runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) }
runCatching { setFeature("http://xml.org/sax/features/external-general-entities", false) }
runCatching { setFeature("http://xml.org/sax/features/external-parameter-entities", false) }
isXIncludeAware = false
isExpandEntityReferences = false
}
/** 접수 pacs.008 원전문에서 핵심 항목 추출. */
fun parsePacs008(rawXml: String): Pacs008View {
val doc = dbf.newDocumentBuilder()
.parse(ByteArrayInputStream(rawXml.toByteArray(StandardCharsets.UTF_8)))
doc.documentElement.normalize()
val tx = firstByLocal(doc.documentElement, "CdtTrfTxInf")
?: throw IllegalArgumentException("CdtTrfTxInf 없음")
val amtEl = firstByLocal(tx, "IntrBkSttlmAmt")
?: throw IllegalArgumentException("IntrBkSttlmAmt 없음")
return Pacs008View(
bizMsgIdr = text(firstByLocal(tx, "EndToEndId")).ifBlank { null },
msgDefIdr = Msg.PACS008,
from = agentMmbId(childByLocal(tx, "DbtrAgt")),
to = agentMmbId(childByLocal(tx, "CdtrAgt")),
dbtrAcct = acctId(childByLocal(tx, "DbtrAcct")),
cdtrAcct = acctId(childByLocal(tx, "CdtrAcct")),
ccy = amtEl.getAttribute("Ccy").ifBlank { "KRW" },
amount = text(amtEl).toLongOrNull() ?: 0L,
creDtTm = text(firstByLocal(doc.documentElement, "CreDtTm")).ifBlank { null },
)
}
/**
* 핵심 명령문 추출. bmi가 비어 있으면 접수센터가 부여한다.
* 원전문 해시(무결성)와 함께 경량 명령문(CoreMessage)을 만든다.
*/
fun toCoreMessage(req: Pacs008View, rawXml: String, assignedBmi: String): CoreMessage {
val bmi = req.bizMsgIdr?.takeIf { it.isNotBlank() } ?: assignedBmi
return CoreMessage(
bmi = bmi,
msgType = req.msgDefIdr.ifBlank { Msg.PACS008 },
senderCode = req.from,
receiverCode = req.to,
debtorAcct = req.dbtrAcct,
creditorAcct = req.cdtrAcct,
amount = req.amount,
currency = req.ccy.ifBlank { "KRW" },
origHash = CoreMessage.sha256Hex(rawXml),
)
}
/** pacs.002.001.10(결과통보) 정식 전문 생성. */
fun writePacs002(msg: Pacs002): String {
val clrSysRef = msg.clrSysRef?.let {
"\n <ClrSysRef>${escape(it.take(35))}</ClrSysRef>"
} ?: ""
return """<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="$PACS002_NS">
<FIToFIPmtStsRpt>
<GrpHdr>
<MsgId>${escape(msg.orgnlBizMsgIdr.ifBlank { "RTGS" }.take(35))}</MsgId>
<CreDtTm>${escape(msg.creDtTm ?: "")}</CreDtTm>
</GrpHdr>
<TxInfAndSts>
<OrgnlEndToEndId>${escape(msg.orgnlBizMsgIdr.take(35))}</OrgnlEndToEndId>
<TxSts>${escape(msg.txSts)}</TxSts>$clrSysRef
</TxInfAndSts>
</FIToFIPmtStsRpt>
</Document>"""
}
// --- DOM 헬퍼 (지역명 기준 탐색) ---
/** 직계 자식 중 지역명이 일치하는 첫 요소. */
private fun childByLocal(parent: Element?, local: String): Element? {
if (parent == null) return null
val kids = parent.childNodes
for (i in 0 until kids.length) {
val n = kids.item(i)
if (n.nodeType == Node.ELEMENT_NODE && localName(n) == local) return n as Element
}
return null
}
/** 하위(자손) 전체에서 지역명이 일치하는 첫 요소. */
private fun firstByLocal(scope: Element, local: String): Element? {
val nl = scope.getElementsByTagNameNS("*", local)
if (nl.length > 0) return nl.item(0) as Element
val nl2 = scope.getElementsByTagName(local) // 네임스페이스 없는 문서 대비
return if (nl2.length > 0) nl2.item(0) as Element else null
}
/** Agent(BranchAndFinancialInstitutionIdentification6) → FinInstnId/ClrSysMmbId/MmbId. */
private fun agentMmbId(agent: Element?): String =
text(agent?.let { firstByLocal(it, "MmbId") })
/** CashAccount38 → Id/Othr/Id (없으면 IBAN). */
private fun acctId(acct: Element?): String {
if (acct == null) return ""
val id = childByLocal(acct, "Id") ?: return ""
childByLocal(id, "Othr")?.let { othr -> return text(childByLocal(othr, "Id")) }
return text(childByLocal(id, "IBAN"))
}
private fun localName(n: Node): String = n.localName ?: n.nodeName.substringAfter(':')
private fun text(e: Element?): String = e?.textContent?.trim() ?: ""
private fun escape(s: String): String = s
.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

View File

@@ -0,0 +1,37 @@
package kr.or.bok.rtgs.common.iso20022
/**
* ISO 20022 전문 모델(정식 스키마 기반, 2026-07-10 전환).
*
* 접수 전문은 **정식 pacs.008.001.08**(FIToFICustomerCreditTransfer), 결과통보는
* **정식 pacs.002.001.10**(FIToFIPaymentStatusReport) 구조를 따른다. 검증은 iso20022.org가
* 배포하는 공식 XSD(resources/iso20022/xsd/)로 수행한다.
*
* - pacs.008은 깊은 중첩 + 네임스페이스라 Jackson DTO 대신 **DOM 파싱**(Iso20022Codec)으로 핵심항목을
* 추출한다(네임스페이스에 견고). 여기서는 추출 결과를 담는 경량 뷰(Pacs008View)만 정의한다.
* - pacs.002는 응답 전용이라 **템플릿 문자열**로 정식 네임스페이스 전문을 생성한다(Pacs002 = 생성 파라미터).
*/
/** pacs.008에서 추출한 핵심 항목(결제에 필요한 최소 집합). */
data class Pacs008View(
val bizMsgIdr: String?, // EndToEndId(거래식별자 BMI). 없으면 접수센터가 부여
val msgDefIdr: String, // 메시지 정의(pacs.008.001.08)
val from: String, // 송신기관 코드 = DbtrAgt/FinInstnId/ClrSysMmbId/MmbId
val to: String, // 수신기관 코드 = CdtrAgt/FinInstnId/ClrSysMmbId/MmbId
val dbtrAcct: String, // 의뢰인(당좌) 계좌 = DbtrAcct/Id/Othr/Id
val cdtrAcct: String, // 수취인(당좌) 계좌 = CdtrAcct/Id/Othr/Id
val ccy: String, // 통화 = IntrBkSttlmAmt/@Ccy
val amount: Long, // 이체금액 = IntrBkSttlmAmt(text)
val creDtTm: String?, // 생성시각 = GrpHdr/CreDtTm
)
/** pacs.002(결과통보) 생성 파라미터. 실제 XML은 Iso20022Codec.writePacs002가 정식 구조로 직렬화. */
data class Pacs002(
val orgnlBizMsgIdr: String = "", // 원거래 BMI → OrgnlEndToEndId
val msgDefIdr: String = "pacs.002.001.10",
val from: String = "", // 정보용(GrpHdr 참고)
val to: String = "",
val txSts: String = "", // RCVD/ACTC/ACSP/ACCC/RJCT/PDNG → TxSts
val clrSysRef: String? = null, // 결제시스템 참조(전역순번/사유) → ClrSysRef
val creDtTm: String? = null,
)

View File

@@ -0,0 +1,37 @@
package kr.or.bok.rtgs.common.iso20022
import java.io.StringReader
import javax.xml.XMLConstants
import javax.xml.transform.stream.StreamSource
import javax.xml.validation.Schema
import javax.xml.validation.SchemaFactory
/**
* XSD 기반 전문 유효성 검사(복원력방안: XSD 스키마로 접수 전문 검증).
* **정식 ISO 20022 pacs.008.001.08 공식 XSD**(iso20022.org 배포본)로 검증한다(2026-07-10 전환).
* XSD 리소스가 없으면 검증을 건너뛴다(개발 편의).
*/
class XmlValidator(private val xsdResourcePath: String = "/iso20022/xsd/pacs.008.001.08.xsd") {
private val schema: Schema? by lazy {
val stream = javaClass.getResourceAsStream(xsdResourcePath) ?: return@lazy null
val factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI)
// XXE 방어: 외부 엔티티/DTD 차단
runCatching { factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "") }
runCatching { factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") }
stream.use { factory.newSchema(StreamSource(it)) }
}
data class Result(val valid: Boolean, val error: String? = null)
fun validate(xml: String): Result {
val s = schema ?: return Result(true) // XSD 미탑재 시 통과
return try {
val validator = s.newValidator()
validator.validate(StreamSource(StringReader(xml)))
Result(true)
} catch (e: Exception) {
Result(false, e.message)
}
}
}

View File

@@ -0,0 +1,122 @@
package kr.or.bok.rtgs.common.ledger
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import kr.or.bok.rtgs.common.TxSts
/**
* 권위 원장(PostgreSQL, 강한 일관성) 엔티티 — 3개 원장 서비스(Dior/Hermes/Prada)가 공유.
* 개인(P2P) 잔액이 아니라 참가기관 당좌계좌 잔액을 기록한다(복원력방안 1.4).
*/
@Entity
@Table(name = "account")
class Account(
@Id
@Column(name = "code", length = 4)
var code: String = "",
@Column(name = "name")
var name: String = "",
/** 당좌계좌 잔액(원). 이체 시 강한 일관성으로 증감. */
@Column(name = "balance", nullable = false)
var balance: Long = 0,
)
@Entity
@Table(name = "transfer")
class TransferRecord(
@Id
@Column(name = "bmi", length = 22)
var bmi: String = "",
@Column(name = "global_seq")
var globalSeq: Long? = null,
@Column(name = "status", length = 8, nullable = false)
@jakarta.persistence.Enumerated(jakarta.persistence.EnumType.STRING)
var status: TxSts = TxSts.PDNG,
@Column(name = "sender_code", length = 4) var senderCode: String = "",
@Column(name = "receiver_code", length = 4) var receiverCode: String = "",
@Column(name = "amount", nullable = false) var amount: Long = 0,
@Column(name = "origin_center", length = 8) var originCenter: String = "",
@Column(name = "created_at") var createdAt: Long = 0,
@Column(name = "updated_at") var updatedAt: Long = 0,
@Column(name = "reason") var reason: String? = null,
)
/**
* 선(先)저널(write-ahead) 로그 — Hermes가 인메모리 잔액 연산 *전에* 순번을 여기에 확정한다.
* 노드사 시 살아있는/재기동 센터가 이 로그로 재생해 무손실 승계(복원력방안 부록A S2).
*/
@Entity
@Table(name = "journal_log")
class JournalLog(
@Id
@Column(name = "global_seq")
var globalSeq: Long = 0,
@Column(name = "bmi", length = 22) var bmi: String = "",
@Column(name = "seq_epoch_millis") var seqEpochMillis: Long = 0,
@Column(name = "applied", nullable = false) var applied: Boolean = false,
)
/**
* 원전문 저장(로컬은 PostgreSQL text 컬럼; 클라우드/BMT는 문서형 DB(MongoDB)).
* 참가기관 pacs.008 원본 XML을 보관 — 센터 간엔 경량명령문만 복제.
*/
@Entity
@Table(name = "raw_message")
class RawMessageEntity(
@Id @Column(name = "bmi", length = 22) var bmi: String = "",
@Column(name = "msg_type", length = 32) var msgType: String = "",
@Column(name = "raw_xml", columnDefinition = "text") var rawXml: String = "",
@Column(name = "orig_hash", length = 64) var origHash: String = "",
@Column(name = "received_at") var receivedAt: Long = 0,
@Column(name = "origin_center", length = 8) var originCenter: String = "",
)
/**
* 결과통보 아웃박스 — 접수센터(Chanel)가 신청/수취기관 앞 송부한 결과 전문(pacs.002) 기록.
* id = bmi:수신기관 (기관별 1건, 재송부 시 멱등 upsert). 로컬은 실제 외부 송신 대신 기록+로그로
* 송부를 모사한다(실외부 전송/콜백은 향후 Gucci 관문 담당).
*/
@Entity
@Table(name = "notification")
class NotificationEntity(
@Id @Column(name = "id", length = 32) var id: String = "",
@Column(name = "bmi", length = 22) var bmi: String = "",
@Column(name = "to_org", length = 4) var toOrg: String = "", // 수신 대상 기관
@Column(name = "role", length = 12) var role: String = "", // APPLICANT(신청기관) / BENEFICIARY(수취기관)
@Column(name = "final_status", length = 8) var finalStatus: String = "",
@Column(name = "pacs002_xml", columnDefinition = "text") var pacs002Xml: String = "",
@Column(name = "origin_center", length = 8) var originCenter: String = "",
@Column(name = "reason") var reason: String? = null,
@Column(name = "delivered", nullable = false) var delivered: Boolean = false,
@Column(name = "attempts", nullable = false) var attempts: Int = 0, // 송부 시도 횟수(재시도)
@Column(name = "created_at") var createdAt: Long = 0,
@Column(name = "delivered_at") var deliveredAt: Long? = null,
)
/**
* 조회 전용 사본(로컬은 PostgreSQL; 클라우드/BMT는 문서형 DB). 결과동기화(Prada)가 최종 확정 기록.
*/
@Entity
@Table(name = "settlement_view")
class SettlementViewEntity(
@Id @Column(name = "bmi", length = 22) var bmi: String = "",
@Column(name = "global_seq") var globalSeq: Long? = null,
@Column(name = "final_status", length = 8) var finalStatus: String = "",
@Column(name = "sender_code", length = 4) var senderCode: String = "",
@Column(name = "receiver_code", length = 4) var receiverCode: String = "",
@Column(name = "amount") var amount: Long = 0,
@Column(name = "origin_center", length = 8) var originCenter: String = "",
@Column(name = "processed_center", length = 8) var processedCenter: String = "",
@Column(name = "debtor_balance_after") var debtorBalanceAfter: Long? = null,
@Column(name = "creditor_balance_after") var creditorBalanceAfter: Long? = null,
@Column(name = "finalized_at") var finalizedAt: Long = 0,
)

View File

@@ -0,0 +1,56 @@
package kr.or.bok.rtgs.common.ops
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.AutoConfiguration
import org.springframework.boot.autoconfigure.AutoConfigureAfter
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate
import org.springframework.context.annotation.Bean
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import javax.sql.DataSource
/**
* B5 서비스 하트비트 — 모든 서비스(공통 자동설정)가 주기적으로 `service_heartbeat`에 생존 신호를 남긴다.
* 헤드리스(Sequencer/Dior/Hermes/Prada) 포함 전 서비스의 liveness를 **DB로** 확인(LouisVuitton 대시보드).
* DataSource가 있는 서비스에만 활성화. 스키마(service_heartbeat)는 Flyway가 보장.
*/
@AutoConfiguration
@AutoConfigureAfter(name = ["org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"])
@ConditionalOnSingleCandidate(DataSource::class)
@EnableScheduling
open class HeartbeatAutoConfiguration {
@Bean
open fun heartbeatWriter(
dataSource: DataSource,
@Value("\${spring.application.name:rtgs}") service: String,
@Value("\${rtgs.center-id:DC1}") center: String,
): HeartbeatWriter = HeartbeatWriter(JdbcTemplate(dataSource), service, center)
}
class HeartbeatWriter(
private val jdbc: JdbcTemplate,
private val service: String,
private val center: String,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val pid = ProcessHandle.current().pid()
@Scheduled(fixedDelayString = "\${rtgs.heartbeat-ms:5000}")
fun beat() {
try {
jdbc.update(
"""
INSERT INTO service_heartbeat(service, center, last_seen, pid)
VALUES (?, ?, ?, ?)
ON CONFLICT (service, center) DO UPDATE SET last_seen = EXCLUDED.last_seen, pid = EXCLUDED.pid
""".trimIndent(),
service, center, System.currentTimeMillis(), pid,
)
} catch (e: Exception) {
// 기동 초기 테이블 미생성 등 일시 오류는 다음 주기에 자동 회복(노이즈 최소화).
log.debug("heartbeat skip: {}", e.message)
}
}
}

View File

@@ -0,0 +1 @@
kr.or.bok.rtgs.common.ops.HeartbeatAutoConfiguration

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
RTGS 공통 로그 설정(전 서비스 공유). 콘솔 + Logstash(TCP :5000, JSON) 전송.
Logstash가 안 떠 있어도 TCP appender가 논블로킹으로 재연결하므로 서비스는 정상 동작.
-->
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<springProperty scope="context" name="appName" source="spring.application.name" defaultValue="rtgs"/>
<springProperty scope="context" name="centerId" source="rtgs.center-id" defaultValue="DC1"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>localhost:5000</destination>
<keepAliveDuration>1 minute</keepAliveDuration>
<reconnectionDelay>5 second</reconnectionDelay>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"service":"${appName}","center":"${centerId}"}</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="LOGSTASH"/>
</root>
</configuration>

View File

@@ -0,0 +1,75 @@
package kr.or.bok.rtgs.common
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class CommonTest {
@Test
fun `bmi is 22 digits and increments`() {
val gen = BmiGenerator("1001")
val a = gen.next()
val b = gen.next()
assertEquals(22, a.length)
assertEquals("1001", BmiGenerator.orgCodeOf(a))
assertTrue(b > a)
}
@Test
fun `quorum thresholds`() {
assertEquals(1, QuorumContext("DC1", 1).quorum)
assertEquals(2, QuorumContext("DC1", 3).quorum)
assertTrue(QuorumContext("DC1", 3).canServe(2)) // 1센터 다운 -> 지속
assertTrue(!QuorumContext("DC1", 3).canServe(1)) // 2센터 다운 -> 장애
}
@Test
fun `core message rejects self-transfer and non-positive amount`() {
assertFailsWith<IllegalArgumentException> {
CoreMessage("b", "t", "1001", "1001", "d", "c", 100, origHash = "h")
}
assertFailsWith<IllegalArgumentException> {
CoreMessage("b", "t", "1001", "1002", "d", "c", 0, origHash = "h")
}
}
@Test
fun `parse official pacs008 and extract core message`() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">
<FIToFICstmrCdtTrf>
<GrpHdr>
<MsgId>2026070810010000000001</MsgId>
<CreDtTm>2026-07-08T17:00:00</CreDtTm>
<NbOfTxs>1</NbOfTxs>
<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf>
</GrpHdr>
<CdtTrfTxInf>
<PmtId><EndToEndId>2026070810010000000001</EndToEndId></PmtId>
<IntrBkSttlmAmt Ccy="KRW">150</IntrBkSttlmAmt>
<ChrgBr>SLEV</ChrgBr>
<Dbtr><Nm>BANK-1001</Nm></Dbtr>
<DbtrAcct><Id><Othr><Id>ACC-1001</Id></Othr></Id></DbtrAcct>
<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>1001</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>
<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>1002</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>
<Cdtr><Nm>BANK-1002</Nm></Cdtr>
<CdtrAcct><Id><Othr><Id>ACC-1002</Id></Othr></Id></CdtrAcct>
</CdtTrfTxInf>
</FIToFICstmrCdtTrf>
</Document>
""".trimIndent()
val req = Iso20022Codec.parsePacs008(xml)
assertEquals("1001", req.from)
assertEquals("1002", req.to)
assertEquals("ACC-1001", req.dbtrAcct)
assertEquals(150, req.amount)
assertEquals("KRW", req.ccy)
val core = Iso20022Codec.toCoreMessage(req, xml, assignedBmi = "X")
assertEquals("2026070810010000000001", core.bmi)
assertEquals(64, core.origHash.length) // SHA-256 hex
}
}