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,19 @@
plugins {
kotlin("jvm")
kotlin("plugin.spring")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
dependencies {
implementation(project(":common"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.kafka:spring-kafka")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

View File

@@ -0,0 +1,15 @@
package kr.or.bok.rtgs.chanel
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
@SpringBootApplication
@EntityScan("kr.or.bok.rtgs.common.ledger")
@EnableJpaRepositories("kr.or.bok.rtgs.chanel.jpa")
class ChanelApplication
fun main(args: Array<String>) {
runApplication<ChanelApplication>(*args)
}

View File

@@ -0,0 +1,77 @@
package kr.or.bok.rtgs.chanel
import kr.or.bok.rtgs.chanel.jpa.AccountRepository
import kr.or.bok.rtgs.chanel.jpa.NotificationRepository
import kr.or.bok.rtgs.chanel.jpa.TransferRepository
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
/**
* 전문송수신 제어 — 신청/접수(pacs.008) 및 조회(inquiry/accounts) API.
* 조회는 권위 원장(PostgreSQL)에서 읽는다(프로토타입; 부하 분리 시 조회 전용 사본으로 확장).
*/
@RestController
class ChanelController(
private val service: ChanelService,
private val transfers: TransferRepository,
private val accounts: AccountRepository,
private val notifications: NotificationRepository,
) {
/** 자금이체 신청/접수: pacs.008 XML → pacs.002 XML. */
@PostMapping(
"/pay/customer",
consumes = [MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE, MediaType.TEXT_PLAIN_VALUE],
produces = [MediaType.APPLICATION_XML_VALUE],
)
fun payCustomer(@RequestBody rawXml: String): String =
Iso20022Codec.writePacs002(service.accept(rawXml))
/** 거래 상태 조회. */
@GetMapping("/inquiry/{bmi}")
fun inquiry(@PathVariable bmi: String): Map<String, Any?> {
val t = transfers.findById(bmi).orElse(null)
?: return mapOf("bmi" to bmi, "status" to "IN_FLIGHT", "note" to "not yet persisted")
return mapOf(
"bmi" to t.bmi,
"globalSeq" to t.globalSeq,
"status" to t.status.name,
"senderCode" to t.senderCode,
"receiverCode" to t.receiverCode,
"amount" to t.amount,
"originCenter" to t.originCenter,
"updatedAt" to t.updatedAt,
"reason" to t.reason,
)
}
/** 참가기관 당좌계좌 잔액 목록(대사·모니터링용). */
@GetMapping("/accounts")
fun accounts(): List<Map<String, Any?>> =
accounts.findAll().sortedBy { it.code }.map {
mapOf("code" to it.code, "name" to it.name, "balance" to it.balance)
}
/** 결과통보 송부 내역(최근 100건). 신청/수취기관 앞 pacs.002 아웃박스. */
@GetMapping("/notifications")
fun notificationList(): List<Map<String, Any?>> =
notifications.findTop100ByOrderByCreatedAtDesc().map(::toMap)
/** 특정 거래의 결과통보(신청기관·수취기관) 내역 — 원문 pacs.002 포함. */
@GetMapping("/notifications/{bmi}")
fun notificationOf(@PathVariable bmi: String): List<Map<String, Any?>> =
notifications.findByBmiOrderByRole(bmi).map { toMap(it, includeXml = true) }
private fun toMap(n: kr.or.bok.rtgs.common.ledger.NotificationEntity, includeXml: Boolean = false): Map<String, Any?> =
buildMap {
put("bmi", n.bmi); put("toOrg", n.toOrg); put("role", n.role)
put("finalStatus", n.finalStatus); put("originCenter", n.originCenter)
put("reason", n.reason); put("delivered", n.delivered)
put("createdAt", n.createdAt); put("deliveredAt", n.deliveredAt)
if (includeXml) put("pacs002", n.pacs002Xml)
}
}

View File

@@ -0,0 +1,87 @@
package kr.or.bok.rtgs.chanel
import com.fasterxml.jackson.databind.ObjectMapper
import kr.or.bok.rtgs.chanel.jpa.RawMessageRepository
import kr.or.bok.rtgs.common.BmiGenerator
import kr.or.bok.rtgs.common.ledger.RawMessageEntity
import kr.or.bok.rtgs.common.InboundRequest
import kr.or.bok.rtgs.common.Topics
import kr.or.bok.rtgs.common.TxSts
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
import kr.or.bok.rtgs.common.iso20022.Pacs002
import kr.or.bok.rtgs.common.iso20022.XmlValidator
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.stereotype.Service
import java.time.Instant
/**
* CHANEL(전문송수신) — 참가기관 pacs.008 접수 → XSD검증 → 경량명령문 추출 →
* 원전문 MongoDB 저장 → 입구(rtgs.inbound) 발행. 응답으로 pacs.002 생성.
*/
@Service
class ChanelService(
private val kafka: KafkaTemplate<String, String>,
private val mapper: ObjectMapper,
private val rawRepo: RawMessageRepository,
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val validator = XmlValidator()
private val fallbackBmi = BmiGenerator("9999") // 요청에 BMI 없을 때 접수센터가 부여
fun accept(rawXml: String): Pacs002 {
// 1) XSD 유효성 검사
val v = validator.validate(rawXml)
if (!v.valid) {
log.warn("XSD invalid: {}", v.error)
return reject("", "", "", "XSD validation failed: ${v.error}")
}
// 2) 파싱 + 경량명령문 추출
val req = try {
Iso20022Codec.parsePacs008(rawXml)
} catch (e: Exception) {
return reject("", "", "", "parse error: ${e.message}")
}
val bmi = req.bizMsgIdr?.takeIf { it.isNotBlank() } ?: fallbackBmi.next()
val core = try {
Iso20022Codec.toCoreMessage(req, rawXml, bmi)
} catch (e: Exception) {
return reject(bmi, req.to, req.from, "business rule: ${e.message}")
}
// 3) 원전문 저장(PostgreSQL text; 클라우드는 문서형 DB)
rawRepo.save(
RawMessageEntity(
bmi = core.bmi, msgType = core.msgType, rawXml = rawXml,
origHash = core.origHash, receivedAt = System.currentTimeMillis(), originCenter = centerId,
)
)
// 4) 입구(rtgs.inbound) 발행 → 순번기로
val inbound = InboundRequest(core = core, originCenter = centerId)
kafka.send(Topics.INBOUND, core.bmi, mapper.writeValueAsString(inbound))
log.info("RCVD bmi={} {}->{} amt={} center={}", core.bmi, core.senderCode, core.receiverCode, core.amount, centerId)
// 5) 접수 응답(RCVD)
return Pacs002(
orgnlBizMsgIdr = core.bmi,
from = "RTGS-$centerId",
to = core.senderCode,
txSts = TxSts.RCVD.name,
clrSysRef = core.bmi,
creDtTm = Instant.now().toString(),
)
}
private fun reject(bmi: String, to: String, sender: String, reason: String) = Pacs002(
orgnlBizMsgIdr = bmi,
from = "RTGS-$centerId",
to = sender,
txSts = TxSts.RJCT.name,
clrSysRef = reason,
creDtTm = Instant.now().toString(),
)
}

View File

@@ -0,0 +1,46 @@
package kr.or.bok.rtgs.chanel
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
/**
* 서비스별 처리 현황(대사) 조회 — 읽기 전용. 각 서비스가 남긴 PostgreSQL 테이블을 최신순으로 반환.
* - transfer : 원장(Dior 접수 PDNG → Hermes 결제 ACSP → Prada 완결 ACCC 흐름)
* - journal_log : Hermes 선저널(write-ahead)
* - settlement_view: Prada 조회 사본
* - raw_message : Chanel 원전문(pacs.008)
*/
@RestController
class LedgerController(private val jdbc: JdbcTemplate) {
private fun q(sql: String, limit: Int) = jdbc.queryForList(sql.replace("{L}", limit.coerceIn(1, 200).toString()))
@GetMapping("/ledger/transfers")
fun transfers(@RequestParam(defaultValue = "20") limit: Int) = q(
"SELECT bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, updated_at " +
"FROM transfer ORDER BY global_seq DESC NULLS LAST LIMIT {L}", limit)
@GetMapping("/ledger/journal")
fun journal(@RequestParam(defaultValue = "20") limit: Int) = q(
"SELECT global_seq, bmi, applied, seq_epoch_millis FROM journal_log ORDER BY global_seq DESC LIMIT {L}", limit)
@GetMapping("/ledger/views")
fun views(@RequestParam(defaultValue = "20") limit: Int) = q(
"SELECT bmi, global_seq, final_status, debtor_balance_after, creditor_balance_after, processed_center, finalized_at " +
"FROM settlement_view ORDER BY global_seq DESC NULLS LAST LIMIT {L}", limit)
@GetMapping("/ledger/rawmessages")
fun rawmessages(@RequestParam(defaultValue = "20") limit: Int) = q(
"SELECT bmi, msg_type, origin_center, received_at FROM raw_message ORDER BY received_at DESC LIMIT {L}", limit)
/** 요약: 상태별 건수 + 총액(대사) */
@GetMapping("/ledger/summary")
fun summary(): Map<String, Any?> {
val byStatus = jdbc.queryForList("SELECT status, count(*) AS cnt FROM transfer GROUP BY status ORDER BY status")
val total = jdbc.queryForObject("SELECT sum(balance) FROM account", Long::class.java)
val accts = jdbc.queryForObject("SELECT count(*) FROM account", Long::class.java)
return mapOf("byStatus" to byStatus, "totalBalance" to total, "accounts" to accts)
}
}

View File

@@ -0,0 +1,55 @@
package kr.or.bok.rtgs.chanel
import kr.or.bok.rtgs.chanel.jpa.RawMessageRepository
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
/**
* 메타/원문 조회 API.
* - /meta/labels: transfer 컬럼의 한글명(DB 코멘트=데이터 사전)을 조회 필드명(camelCase)에 매핑해 반환.
* 화면 라벨의 단일 출처가 DB 코멘트가 되도록 함.
* - /rawmessage/{bmi}: 접수된 ISO20022 pacs.008 원문 전문(XML) 조회.
*/
@RestController
class MetaController(
private val jdbc: JdbcTemplate,
private val rawRepo: RawMessageRepository,
) {
@GetMapping("/meta/labels")
fun labels(): Map<String, String> {
val rows = jdbc.queryForList(
"""
SELECT a.attname AS col, col_description(a.attrelid, a.attnum) AS ko
FROM pg_attribute a
WHERE a.attrelid = 'transfer'::regclass AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum
""".trimIndent()
)
val m = LinkedHashMap<String, String>()
for (r in rows) {
val col = r["col"] as? String ?: continue
val ko = r["ko"] as? String ?: continue
m[toCamel(col)] = ko
}
return m
}
@GetMapping("/rawmessage/{bmi}")
fun rawMessage(@PathVariable bmi: String): Map<String, Any?> {
val e = rawRepo.findById(bmi).orElse(null)
?: return mapOf("bmi" to bmi, "found" to false)
return mapOf(
"bmi" to e.bmi,
"msgType" to e.msgType,
"receivedAt" to e.receivedAt,
"originCenter" to e.originCenter,
"rawXml" to e.rawXml,
"found" to true,
)
}
private fun toCamel(s: String): String =
s.split("_").mapIndexed { i, p -> if (i == 0) p else p.replaceFirstChar { it.uppercase() } }.joinToString("")
}

View File

@@ -0,0 +1,75 @@
package kr.or.bok.rtgs.chanel
import com.fasterxml.jackson.databind.ObjectMapper
import kr.or.bok.rtgs.chanel.jpa.NotificationRepository
import kr.or.bok.rtgs.common.SettlementNotice
import kr.or.bok.rtgs.common.Topics
import kr.or.bok.rtgs.common.TxSts
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
import kr.or.bok.rtgs.common.iso20022.Pacs002
import kr.or.bok.rtgs.common.ledger.NotificationEntity
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Service
import java.time.Instant
/**
* 결과통보(이체결과 송부) — Gucci에서 Chanel로 이관(2026-07-10). Chanel 헌장 "접수 + 처리결과 통보".
*
* Prada가 완결(ACCC/RJCT) 후 발행한 rtgs.notify를 받아, **접수센터(originCenter)의 Chanel만**
* 신청기관(송신)·수취기관 앞 최종 pacs.002를 생성해 송부한다(개요 프로세스 (4)).
* - 신청기관(APPLICANT): 항상 결과 통보(성공/반려)
* - 수취기관(BENEFICIARY): 완결(ACCC) 시 입금 통보
* 로컬은 실제 외부 전송 대신 아웃박스(notification) 기록 + 로그로 송부를 모사한다.
* (실외부 채널/콜백 전송·전달보장은 향후 Gucci 관문이 담당)
*/
@Service
class NotificationService(
private val notifications: NotificationRepository,
private val mapper: ObjectMapper,
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
) {
private val log = LoggerFactory.getLogger(javaClass)
// 센터별 그룹(chanel-DC1/DC2/DC3): 각 센터가 notify 전량 소비 후 origin만 통보.
@KafkaListener(topics = [Topics.NOTIFY], groupId = "chanel-\${rtgs.center-id:DC1}", concurrency = "1")
fun onNotice(payload: String) {
val n = mapper.readValue(payload, SettlementNotice::class.java)
if (n.originCenter != centerId) return // 접수센터만 통보 책임
val pacs002 = Iso20022Codec.writePacs002(
Pacs002(
orgnlBizMsgIdr = n.bmi,
from = "RTGS-$centerId",
to = n.senderCode,
txSts = n.finalStatus.name,
clrSysRef = n.reason ?: "seq:${n.globalSeq}",
creDtTm = Instant.now().toString(),
)
)
// 신청기관(송신) 앞 결과 통보 — 성공/반려 모두
send(n, n.senderCode, "APPLICANT", pacs002)
// 수취기관 앞 입금 통보 — 완결(ACCC)만
if (n.finalStatus == TxSts.ACCC) send(n, n.receiverCode, "BENEFICIARY", pacs002)
log.info("NOTIFY 송부 bmi={} status={} → 신청기관 {}{}",
n.bmi, n.finalStatus, n.senderCode,
if (n.finalStatus == TxSts.ACCC) " · 수취기관 ${n.receiverCode}" else "")
}
private fun send(n: SettlementNotice, toOrg: String, role: String, pacs002: String) {
val now = System.currentTimeMillis()
// 생성 시점엔 아웃박스에 pending(delivered=false)으로 기록.
// 실제 외부 기관 앞 송부(콜백 POST·재시도·ACK)는 Gucci 관문(G2)이 담당한다.
notifications.save(
NotificationEntity(
id = "${n.bmi}:$toOrg", bmi = n.bmi, toOrg = toOrg, role = role,
finalStatus = n.finalStatus.name, pacs002Xml = pacs002,
originCenter = n.originCenter, reason = n.reason,
delivered = false, attempts = 0, createdAt = now, deliveredAt = null,
)
)
}
}

View File

@@ -0,0 +1,20 @@
package kr.or.bok.rtgs.chanel.jpa
import kr.or.bok.rtgs.common.ledger.Account
import kr.or.bok.rtgs.common.ledger.NotificationEntity
import kr.or.bok.rtgs.common.ledger.RawMessageEntity
import kr.or.bok.rtgs.common.ledger.TransferRecord
import org.springframework.data.jpa.repository.JpaRepository
/** 조회(inquiry)용 읽기 접근 + 원전문 저장 — 권위 원장(PostgreSQL). */
interface AccountRepository : JpaRepository<Account, String>
interface TransferRepository : JpaRepository<TransferRecord, String>
interface RawMessageRepository : JpaRepository<RawMessageEntity, String>
/** 결과통보 아웃박스 — 신청/수취기관 앞 송부한 pacs.002 기록. */
interface NotificationRepository : JpaRepository<NotificationEntity, String> {
fun findByBmiOrderByRole(bmi: String): List<NotificationEntity>
fun findTop100ByOrderByCreatedAtDesc(): List<NotificationEntity>
}

View File

@@ -0,0 +1,34 @@
spring:
application:
name: chanel
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
username: ${POSTGRES_USER:rtgs}
password: ${POSTGRES_PASSWORD:rtgs}
jpa:
hibernate:
ddl-auto: none
open-in-view: false
kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
consumer:
group-id: chanel
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
server:
port: 8091
rtgs:
center-id: ${CENTER_ID:DC1}
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics