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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
34
backend/chanel/src/main/resources/application.yml
Normal file
34
backend/chanel/src/main/resources/application.yml
Normal 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
|
||||
Reference in New Issue
Block a user