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

37
backend/build.gradle.kts Normal file
View File

@@ -0,0 +1,37 @@
plugins {
// Applied per-subproject; declared here (apply false) so versions are shared.
kotlin("jvm") version "2.0.20" apply false
kotlin("plugin.spring") version "2.0.20" apply false
kotlin("plugin.jpa") version "2.0.20" apply false
id("org.springframework.boot") version "3.3.4" apply false
id("io.spring.dependency-management") version "1.1.6" apply false
}
allprojects {
group = "kr.or.bok.rtgs"
version = "0.1.0"
repositories {
mavenCentral()
}
}
subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
// All modules target Java 21 bytecode (cloud parity: Temurin 21).
the<JavaPluginExtension>().toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
}

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

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
}
}

View File

@@ -0,0 +1,20 @@
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")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-web") // 메트릭/헬스 노출(B4)
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,54 @@
package kr.or.bok.rtgs.dior
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.or.bok.rtgs.common.ledger.TransferRecord
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.data.repository.query.Param
@SpringBootApplication
@EntityScan("kr.or.bok.rtgs.common.ledger")
@EnableJpaRepositories(considerNestedRepositories = true)
class DiorApplication {
@Bean
fun objectMapper(): ObjectMapper = jacksonObjectMapper()
}
interface TransferRepository : JpaRepository<TransferRecord, String> {
/**
* 접수내역을 PDNG로 삽입하되, 이미 존재하면(=Hermes/타 소비자가 먼저 만든 경우) 아무것도 안 함.
* 원자적 upsert라 Dior-Hermes 동시 삽입 경합에서 중복키 예외가 발생하지 않는다.
*/
@Modifying
@Query(
value = """
INSERT INTO transfer
(bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, created_at, updated_at)
VALUES
(:bmi, :seq, 'PDNG', :sender, :receiver, :amount, :origin, :createdAt, :updatedAt)
ON CONFLICT (bmi) DO NOTHING
""",
nativeQuery = true,
)
fun insertPdngIfAbsent(
@Param("bmi") bmi: String,
@Param("seq") seq: Long,
@Param("sender") sender: String,
@Param("receiver") receiver: String,
@Param("amount") amount: Long,
@Param("origin") origin: String,
@Param("createdAt") createdAt: Long,
@Param("updatedAt") updatedAt: Long,
): Int
}
fun main(args: Array<String>) {
runApplication<DiorApplication>(*args)
}

View File

@@ -0,0 +1,41 @@
package kr.or.bok.rtgs.dior
import com.fasterxml.jackson.databind.ObjectMapper
import kr.or.bok.rtgs.common.JournalEntry
import kr.or.bok.rtgs.common.Topics
import org.slf4j.LoggerFactory
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* DIOR(접수동기화) — 저널(rtgs.journal)을 구독해 접수 내역을 권위 원장(PostgreSQL)에
* 대기상태(PDNG)로 영구 저장한다. BMI 중복체크로 멱등성 보장(센터 간 동일 저널 재생).
*/
@Service
class DiorService(
private val transfers: TransferRepository,
private val mapper: ObjectMapper,
) {
private val log = LoggerFactory.getLogger(javaClass)
// 센터별 그룹(dior-DC1/DC2/DC3): 각 센터가 저널 전량을 독립 재생.
@KafkaListener(topics = [Topics.JOURNAL], groupId = "dior-\${rtgs.center-id:DC1}", concurrency = "1")
@Transactional
fun onJournal(payload: String) {
val e = mapper.readValue(payload, JournalEntry::class.java)
// 원자적 upsert: 이미 있으면(Hermes/타 소비자가 먼저 만든 경우) 아무 일도 안 함 → 중복키 예외 없음.
val inserted = transfers.insertPdngIfAbsent(
bmi = e.core.bmi,
seq = e.globalSeq,
sender = e.core.senderCode,
receiver = e.core.receiverCode,
amount = e.core.amount,
origin = e.originCenter,
createdAt = e.seqEpochMillis,
updatedAt = System.currentTimeMillis(),
)
if (inserted > 0) log.info("PDNG seq=#{} bmi={}", e.globalSeq, e.core.bmi)
else log.debug("PDNG skip(exists) seq=#{} bmi={}", e.globalSeq, e.core.bmi)
}
}

View File

@@ -0,0 +1,30 @@
spring:
application:
name: dior
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: dior
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
server:
port: 8092
rtgs:
center-id: ${CENTER_ID:DC1}
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics

13
backend/gradle.properties Normal file
View File

@@ -0,0 +1,13 @@
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
kotlin.code.style=official
# rtgs 빌드는 반드시 JDK21 (Gradle 8.10.2는 JDK26 미지원). CLI/IDE import 모두 이 JDK 사용.
org.gradle.java.home=C:/ai-dev/apps/jdk-21
# Dependency versions (kept in sync with cloud PoC 2/3차)
springBootVersion=3.3.4
kotlinVersion=2.0.20
springDepMgmtVersion=1.1.6
hazelcastVersion=5.5.0

Binary file not shown.

View File

@@ -0,0 +1,8 @@
#Wed Jul 08 17:43:33 KST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
backend/gradlew vendored Normal file
View File

@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
backend/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,20 @@
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-jdbc")
implementation("org.springframework.security:spring-security-crypto") // BCrypt(경량, 전체 Security 미도입)
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")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

View File

@@ -0,0 +1,65 @@
package kr.or.bok.rtgs.gucci
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
/** 로그인 실패(자격증명 불일치). */
class AuthFailedException(msg: String) : RuntimeException(msg)
/**
* 참가기관/관리자 로그인 인증. 자격증명(비밀키)은 **LouisVuitton이 관리하는 app_user**(권한 마스터)에서
* 확인한다(인증=Gucci, 권한마스터=LouisVuitton). 비밀키는 **BCrypt 해시**로 저장·대조. 인증 성공 시 JWT 발급.
*/
@Service
class AuthService(
private val jdbc: JdbcTemplate,
private val jwt: JwtService,
private val encoder: PasswordEncoder,
) {
private val log = LoggerFactory.getLogger(javaClass)
data class LoginResult(
val token: String, val role: String, val orgCode: String?,
val expiresIn: Long, val mustChangePassword: Boolean,
)
fun login(username: String, secret: String, nowMs: Long): LoginResult {
val rows = jdbc.queryForList(
"SELECT role, org_code, secret, must_change_password FROM app_user WHERE username = ?", username,
)
val row = rows.firstOrNull() ?: run {
log.warn("AUTH login FAIL user={} reason=unknown-user", username)
throw AuthFailedException("invalid credentials")
}
val stored = row["secret"] as? String
if (stored.isNullOrBlank() || !encoder.matches(secret, stored)) {
log.warn("AUTH login FAIL user={} reason=bad-secret", username)
throw AuthFailedException("invalid credentials")
}
val role = row["role"] as? String ?: ""
val orgCode = row["org_code"] as? String
val mustChange = row["must_change_password"] as? Boolean ?: false
val token = jwt.issue(Principal(username, role, orgCode), nowMs)
log.info("AUTH login OK user={} role={} org={} mustChange={}", username, role, orgCode, mustChange)
return LoginResult(token, role, orgCode, jwt.ttl(), mustChange)
}
/** 초기암호(또는 비밀번호) 변경. 기존 비번 확인 후 새 비번(BCrypt) 저장 + 변경필요 해제. */
fun changePassword(username: String, oldSecret: String, newSecret: String) {
if (newSecret.length < 1) throw AuthFailedException("new secret too short")
val rows = jdbc.queryForList("SELECT secret FROM app_user WHERE username = ?", username)
val stored = rows.firstOrNull()?.get("secret") as? String
?: throw AuthFailedException("invalid credentials")
if (!encoder.matches(oldSecret, stored)) {
log.warn("AUTH changePw FAIL user={} reason=bad-old", username)
throw AuthFailedException("invalid credentials")
}
jdbc.update(
"UPDATE app_user SET secret = ?, must_change_password = false WHERE username = ?",
encoder.encode(newSecret), username,
)
log.info("AUTH changePw OK user={}", username)
}
}

View File

@@ -0,0 +1,71 @@
package kr.or.bok.rtgs.gucci
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
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.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
/**
* 콜백 레지스트리 관리(관리자) + 기관 수신 sink(데모).
* - 레지스트리: 기관별 결과통보 수신 URL 조회/등록.
* - sink: 로컬 데모용 "기관 수신 서버" 모사 — pacs.002를 받아 ACK(200) 응답.
*/
@RestController
class CallbackController(
private val jdbc: JdbcTemplate,
private val jwt: JwtService,
) {
private val log = LoggerFactory.getLogger(javaClass)
data class CallbackReq(val callbackUrl: String = "", val active: Boolean = true)
/** 콜백 레지스트리 목록(관리자). */
@GetMapping("/gucci/callbacks", produces = [MediaType.APPLICATION_JSON_VALUE])
fun list(@RequestHeader("Authorization", required = false) authz: String?): List<Map<String, Any?>> {
requireAdmin(authz)
return jdbc.queryForList("SELECT org_code, callback_url, active FROM institution_endpoint ORDER BY org_code")
}
/** 기관 콜백 URL 등록/수정(관리자). */
@PutMapping("/gucci/callbacks/{org}", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun upsert(
@PathVariable org: String,
@RequestBody req: CallbackReq,
@RequestHeader("Authorization", required = false) authz: String?,
): Map<String, Any?> {
requireAdmin(authz)
jdbc.update(
"INSERT INTO institution_endpoint(org_code, callback_url, active, created_at) VALUES (?,?,?,?) " +
"ON CONFLICT (org_code) DO UPDATE SET callback_url = EXCLUDED.callback_url, active = EXCLUDED.active",
org, req.callbackUrl, req.active, System.currentTimeMillis(),
)
log.info("CALLBACK upsert org={} url={} active={}", org, req.callbackUrl, req.active)
return mapOf("ok" to true, "orgCode" to org, "callbackUrl" to req.callbackUrl, "active" to req.active)
}
/**
* 기관 수신 sink(데모) — 실제 참가기관 수신 서버 대역. pacs.002를 받아 로그 후 ACK.
* 로컬 왕복 시연용(운영에선 각 기관의 실제 엔드포인트).
*/
@PostMapping("/gucci/sink/{org}", consumes = [MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE])
fun sink(@PathVariable org: String, @RequestBody body: String): Map<String, Any?> {
log.info("SINK 수신 org={} bytes={} (기관 수신 모사 → ACK)", org, body.length)
return mapOf("ack" to true, "org" to org, "received" to body.length)
}
private fun requireAdmin(authz: String?) {
val token = authz?.removePrefix("Bearer ")?.trim()
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "missing bearer token")
val p = try { jwt.verify(token, System.currentTimeMillis()) }
catch (e: JwtException) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid token") }
if (p.role != "ADMIN") throw ResponseStatusException(HttpStatus.FORBIDDEN, "admin only")
}
}

View File

@@ -0,0 +1,66 @@
package kr.or.bok.rtgs.gucci
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
/**
* G2 결과 콜백 송부(전달 보장) — Chanel이 아웃박스(notification, delivered=false)에 적재한 결과 전문을
* 기관별 콜백 URL로 **POST 송부**하고, 2xx ACK 시 delivered=true로 확정한다. 실패는 attempts를 늘려
* 다음 주기에 **재시도**하며, 최대 시도 초과분은 보류(운영 알람 대상). 폴링 아웃박스 방식이라
* 서비스 재기동에도 유실 없이 이어서 송부한다(at-least-once + 수신측 dedup 전제).
*/
@Service
class DeliveryService(
private val jdbc: JdbcTemplate,
private val http: RestClient,
@Value("\${gucci.delivery-max-attempts:5}") private val maxAttempts: Int,
@Value("\${gucci.delivery-batch:50}") private val batch: Int,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Scheduled(fixedDelayString = "\${gucci.delivery-interval-ms:3000}")
fun deliverPending() {
val rows = jdbc.queryForList(
"SELECT id, to_org, pacs002_xml, attempts FROM notification " +
"WHERE delivered = false AND attempts < ? ORDER BY created_at LIMIT ?",
maxAttempts, batch,
)
if (rows.isEmpty()) return
for (r in rows) {
val id = r["id"] as String
val toOrg = r["to_org"] as String
val xml = r["pacs002_xml"] as String
val attempts = (r["attempts"] as Number).toInt()
val url = endpointOf(toOrg)
if (url == null) {
jdbc.update("UPDATE notification SET attempts = attempts + 1 WHERE id = ?", id)
log.warn("DELIVER skip id={} org={} reason=no-endpoint attempts={}", id, toOrg, attempts + 1)
continue
}
try {
http.post().uri(url).contentType(MediaType.APPLICATION_XML)
.body(xml).retrieve().toBodilessEntity() // 2xx ACK 아니면 예외
jdbc.update(
"UPDATE notification SET delivered = true, attempts = attempts + 1, delivered_at = ? WHERE id = ?",
System.currentTimeMillis(), id,
)
log.info("DELIVER ok id={} org={} url={}", id, toOrg, url)
} catch (e: Exception) {
jdbc.update("UPDATE notification SET attempts = attempts + 1 WHERE id = ?", id)
log.warn("DELIVER fail id={} org={} attempts={} err={}", id, toOrg, attempts + 1, e.message)
}
}
}
private fun endpointOf(orgCode: String): String? =
jdbc.queryForList(
"SELECT callback_url FROM institution_endpoint WHERE org_code = ? AND active = true", orgCode,
).firstOrNull()?.get("callback_url") as? String
}

View File

@@ -0,0 +1,56 @@
package kr.or.bok.rtgs.gucci
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
* 유량제어(rate limit) — subject(기관)별 고정 윈도우 카운터. 순번기 상한·코어 보호(성능목표).
* 프로토타입은 인메모리(센터 로컬). 다센터·다인스턴스는 분산 카운터(Redis 등)로 확장.
*/
@Component
class RateLimiter(
@Value("\${gucci.rate-limit-per-window:50}") private val limit: Int,
@Value("\${gucci.rate-window-seconds:10}") private val windowSeconds: Long,
) {
private class Window(val startSec: Long) { val count = AtomicInteger(0) }
private val windows = ConcurrentHashMap<String, Window>()
/** 허용이면 true. 초과면 false(429). nowMs 주입(테스트 용이). */
fun allow(subject: String, nowMs: Long): Boolean {
val bucket = nowMs / 1000 / windowSeconds
val w = windows.compute(subject) { _, cur ->
if (cur == null || cur.startSec != bucket) Window(bucket) else cur
}!!
return w.count.incrementAndGet() <= limit
}
}
/**
* 재전송(replay) 방지 — 요청별 nonce+timestamp. 시각오차 초과 또는 nonce 재사용이면 거부.
* 부인방지(원전문 해시)와 함께 이중제출·재생 공격 차단.
*/
@Component
class ReplayGuard(
@Value("\${gucci.replay-skew-seconds:30}") private val skewSeconds: Long,
@Value("\${gucci.replay-nonce-ttl-seconds:120}") private val nonceTtlSeconds: Long,
) {
private val seen = ConcurrentHashMap<String, Long>() // nonce -> 만료 epochSec
sealed class Result { object Ok : Result(); data class Reject(val reason: String) : Result() }
/** 검사 + 통과 시 nonce 등록. nowMs 주입. */
fun check(nonce: String?, timestampSec: Long?, nowMs: Long): Result {
if (nonce.isNullOrBlank() || timestampSec == null) return Result.Reject("missing nonce/timestamp")
val nowSec = nowMs / 1000
if (kotlin.math.abs(nowSec - timestampSec) > skewSeconds) return Result.Reject("timestamp out of window")
prune(nowSec)
val prev = seen.putIfAbsent(nonce, nowSec + nonceTtlSeconds)
return if (prev != null) Result.Reject("nonce replayed") else Result.Ok
}
private fun prune(nowSec: Long) {
if (seen.size > 10_000) seen.entries.removeIf { it.value < nowSec }
}
}

View File

@@ -0,0 +1,32 @@
package kr.or.bok.rtgs.gucci
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.client.RestClient
/**
* GUCCI (Global User Communication Control Interface) — 외부 경계 관문(북-남 트래픽).
*
* 로컬 단계 G1: 참가기관 로그인 인증(JWT 발급) + API 인증(토큰·서명·재전송 차단) + 유량제어 +
* 코어(Chanel) 리버스프록시 + 감사로그. 센터간 헬스체크·정족수/펜싱·GSLB 라우팅은 다센터(G3) 단계.
*
* 원칙: **전역 결제순서는 Sequencer 단독**, Gucci는 관문/라우팅/유량제어만(순서 결정 아님).
*/
@SpringBootApplication
@EnableScheduling
class GucciApplication {
@Bean
fun restClient(): RestClient = RestClient.create()
/** 비밀키 해시(BCrypt+솔트). 로그인 검증·자격증명 마이그레이션에 공용. */
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}
fun main(args: Array<String>) {
runApplication<GucciApplication>(*args)
}

View File

@@ -0,0 +1,166 @@
package kr.or.bok.rtgs.gucci
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
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.RequestHeader
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestClient
import org.springframework.web.server.ResponseStatusException
/**
* GUCCI 관문 API — 인증(로그인·JWT) + 인증된 리버스프록시(→ Chanel) + 유량제어 + 재전송 차단 + 감사로그.
* 참가기관은 Gucci(:8095)를 통해 접수/조회하고, 코어(Chanel:8091)는 내부에 둔다.
*/
@RestController
class GucciController(
private val auth: AuthService,
private val jwt: JwtService,
private val rateLimiter: RateLimiter,
private val replayGuard: ReplayGuard,
private val http: RestClient,
@Value("\${gucci.chanel-base-url}") private val chanelBase: String,
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
) {
private val log = LoggerFactory.getLogger(javaClass)
private fun now() = System.currentTimeMillis()
data class LoginReq(val username: String = "", val secret: String = "")
data class ChangePwReq(val username: String = "", val oldSecret: String = "", val newSecret: String = "")
/** ① 로그인 인증 → JWT 발급. */
@PostMapping("/gucci/auth/login")
fun login(@RequestBody req: LoginReq): Map<String, Any?> = try {
val r = auth.login(req.username, req.secret, now())
mapOf("token" to r.token, "role" to r.role, "orgCode" to r.orgCode,
"tokenType" to "Bearer", "expiresIn" to r.expiresIn, "mustChangePassword" to r.mustChangePassword)
} catch (e: AuthFailedException) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials")
}
/** ①-2 초기암호/비밀번호 변경. */
@PostMapping("/gucci/auth/change-password")
fun changePassword(@RequestBody req: ChangePwReq): Map<String, Any?> = try {
auth.changePassword(req.username, req.oldSecret, req.newSecret)
mapOf("ok" to true)
} catch (e: AuthFailedException) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials")
}
/** ② 자금이체 신청(접수) — 인증·유량·재전송·기관바인딩 검사 후 Chanel로 전달. */
@PostMapping(
"/gucci/pay/customer",
consumes = [MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE, MediaType.TEXT_PLAIN_VALUE],
produces = [MediaType.APPLICATION_XML_VALUE],
)
fun pay(
@RequestBody rawXml: String,
@RequestHeader(value = "Authorization", required = false) authz: String?,
@RequestHeader(value = "X-Nonce", required = false) nonce: String?,
@RequestHeader(value = "X-Timestamp", required = false) timestamp: String?,
): String {
val p = authenticate(authz)
requireRole(p, "ORG_S", "ADMIN") // 송신기관만 신청
enforceRate(p)
enforceReplay(p, nonce, timestamp)
enforceOrgBinding(p, rawXml) // 토큰 기관 == 전문 송신기관
audit("PAY", p, "allow", null)
return forwardPost("/pay/customer", rawXml)
}
/** ③ 거래 상태 조회 — 인증된 참가기관/관리자. */
@GetMapping("/gucci/inquiry/{bmi}", produces = [MediaType.APPLICATION_JSON_VALUE])
fun inquiry(@PathVariable bmi: String, @RequestHeader("Authorization", required = false) authz: String?): String {
val p = authenticate(authz); enforceRate(p)
audit("INQUIRY", p, "allow", bmi)
return forwardGet("/inquiry/$bmi")
}
/** ④ 참가기관 잔액 목록 — 관리자만(대사·모니터링). */
@GetMapping("/gucci/accounts", produces = [MediaType.APPLICATION_JSON_VALUE])
fun accounts(@RequestHeader("Authorization", required = false) authz: String?): String {
val p = authenticate(authz); requireRole(p, "ADMIN"); enforceRate(p)
audit("ACCOUNTS", p, "allow", null)
return forwardGet("/accounts")
}
/** ⑤ 센터 헬스체크(관측용). 로컬은 단일 센터. 다센터·정족수/펜싱은 G3. */
@GetMapping("/gucci/health/centers", produces = [MediaType.APPLICATION_JSON_VALUE])
fun centers(): Map<String, Any?> {
val chanelUp = runCatching { http.get().uri("$chanelBase/accounts").retrieve().toBodilessEntity() }.isSuccess
return mapOf(
"self" to centerId,
"centers" to listOf(mapOf("id" to centerId, "core(chanel)" to if (chanelUp) "UP" else "DOWN")),
"note" to "단일 센터(G1). 다센터 정족수/펜싱은 G3(고사양 PC 이후).",
)
}
// ---------- 내부 검사·전달 ----------
private fun authenticate(authz: String?): Principal {
val token = authz?.removePrefix("Bearer ")?.trim()
if (token.isNullOrBlank()) throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "missing bearer token")
return try { jwt.verify(token, now()) }
catch (e: JwtException) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid token: ${e.message}") }
}
private fun requireRole(p: Principal, vararg allowed: String) {
if (p.role !in allowed) {
audit("AUTHZ", p, "deny", "role ${p.role} not in ${allowed.toList()}")
throw ResponseStatusException(HttpStatus.FORBIDDEN, "role not permitted")
}
}
private fun enforceRate(p: Principal) {
if (!rateLimiter.allow(p.subject, now())) {
audit("RATE", p, "deny", "rate limit exceeded")
throw ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "rate limit exceeded")
}
}
private fun enforceReplay(p: Principal, nonce: String?, timestamp: String?) {
val ts = timestamp?.toLongOrNull()
when (val r = replayGuard.check(nonce, ts, now())) {
is ReplayGuard.Result.Reject -> {
audit("REPLAY", p, "deny", r.reason)
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "replay check failed: ${r.reason}")
}
else -> {}
}
}
/** 토큰의 기관코드와 전문 송신기관(DbtrAgt MmbId) 일치 강제(관리자 예외). */
private fun enforceOrgBinding(p: Principal, rawXml: String) {
if (p.role == "ADMIN") return
val sender = runCatching { Iso20022Codec.parsePacs008(rawXml).from }.getOrNull() ?: return // 파싱 실패는 Chanel XSD가 반려
if (p.orgCode != sender) {
audit("ORGBIND", p, "deny", "token org ${p.orgCode} != sender $sender")
throw ResponseStatusException(HttpStatus.FORBIDDEN, "token org does not match message sender")
}
}
private fun forwardPost(path: String, body: String): String = try {
http.post().uri("$chanelBase$path")
.contentType(MediaType.APPLICATION_XML)
.body(body).retrieve().body(String::class.java) ?: ""
} catch (e: Exception) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "core unavailable: ${e.message}")
}
private fun forwardGet(path: String): String = try {
http.get().uri("$chanelBase$path").retrieve().body(String::class.java) ?: ""
} catch (e: Exception) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "core unavailable: ${e.message}")
}
private fun audit(event: String, p: Principal, decision: String, detail: String?) {
log.info("AUDIT event={} subject={} role={} org={} decision={} detail={}",
event, p.subject, p.role, p.orgCode, decision, detail ?: "")
}
}

View File

@@ -0,0 +1,76 @@
package kr.or.bok.rtgs.gucci
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.charset.StandardCharsets
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/** JWT 검증 실패(만료/서명오류/형식). */
class JwtException(msg: String) : RuntimeException(msg)
/** 토큰에서 복원한 인증 주체. */
data class Principal(val subject: String, val role: String, val orgCode: String?)
/**
* 경량 JWT(HS256) — 외부 라이브러리 없이 표준 구조(header.payload.signature)로 서명·검증.
* 프로토타입 자족 구현. 운영은 표준 라이브러리/키관리(HSM·회전)로 대체.
*/
@Component
class JwtService(
private val mapper: ObjectMapper,
@Value("\${gucci.jwt-secret}") private val secret: String,
@Value("\${gucci.jwt-ttl-seconds:1800}") private val ttlSeconds: Long,
) {
private val enc = Base64.getUrlEncoder().withoutPadding()
private val dec = Base64.getUrlDecoder()
fun ttl(): Long = ttlSeconds
/** 주체 정보로 토큰 발급(iat/exp 포함). nowMs는 호출측이 주입(테스트 용이). */
fun issue(p: Principal, nowMs: Long): String {
val header = mapOf("alg" to "HS256", "typ" to "JWT")
val iat = nowMs / 1000
val payload = mapOf(
"sub" to p.subject, "role" to p.role, "org" to p.orgCode,
"iat" to iat, "exp" to iat + ttlSeconds,
)
val h = enc.encodeToString(mapper.writeValueAsBytes(header))
val pl = enc.encodeToString(mapper.writeValueAsBytes(payload))
val signingInput = "$h.$pl"
return "$signingInput.${sign(signingInput)}"
}
/** 토큰 검증 후 주체 반환. 서명 불일치·만료·형식오류면 JwtException. */
fun verify(token: String, nowMs: Long): Principal {
val parts = token.split(".")
if (parts.size != 3) throw JwtException("malformed token")
val expected = sign("${parts[0]}.${parts[1]}")
if (!constantTimeEquals(expected, parts[2])) throw JwtException("bad signature")
val claims: Map<String, Any?> = mapper.readValue(dec.decode(parts[1]), Map::class.java)
.let { @Suppress("UNCHECKED_CAST") (it as Map<String, Any?>) }
val exp = (claims["exp"] as? Number)?.toLong() ?: throw JwtException("no exp")
if (nowMs / 1000 > exp) throw JwtException("expired")
return Principal(
subject = claims["sub"] as? String ?: throw JwtException("no sub"),
role = claims["role"] as? String ?: "",
orgCode = claims["org"] as? String,
)
}
private fun sign(input: String): String {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256"))
return enc.encodeToString(mac.doFinal(input.toByteArray(StandardCharsets.UTF_8)))
}
private fun constantTimeEquals(a: String, b: String): Boolean {
val x = a.toByteArray(); val y = b.toByteArray()
if (x.size != y.size) return false
var r = 0
for (i in x.indices) r = r or (x[i].toInt() xor y[i].toInt())
return r == 0
}
}

View File

@@ -0,0 +1,35 @@
package kr.or.bok.rtgs.gucci
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationRunner
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Component
/**
* 자격증명 마이그레이션 — 기동 시 `app_user.secret` 중 **평문(BCrypt 아님)** 을 BCrypt 해시로 승격한다.
* BCrypt 해시는 `$2`로 시작하므로 이미 해시면 건너뛴다(멱등). 시드/신규 사용자가 평문으로 들어와도
* 다음 기동에 자동 해시되어 **평문 저장이 남지 않는다**. 운영에선 사용자 생성 시점에 해시 권장.
*/
@Component
class SecretMigrator(
private val jdbc: JdbcTemplate,
private val encoder: PasswordEncoder,
) : ApplicationRunner {
private val log = LoggerFactory.getLogger(javaClass)
override fun run(args: org.springframework.boot.ApplicationArguments?) {
val plain = jdbc.queryForList(
"SELECT username, secret FROM app_user WHERE secret IS NOT NULL AND secret NOT LIKE '\$2%'",
)
if (plain.isEmpty()) return
var n = 0
for (r in plain) {
val u = r["username"] as String
val raw = r["secret"] as String
jdbc.update("UPDATE app_user SET secret = ? WHERE username = ?", encoder.encode(raw), u)
n++
}
log.warn("자격증명 마이그레이션: 평문 {}건 → BCrypt 해시로 승격", n)
}
}

View File

@@ -0,0 +1,36 @@
spring:
application:
name: gucci
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
username: ${POSTGRES_USER:rtgs}
password: ${POSTGRES_PASSWORD:rtgs}
server:
port: 8095
rtgs:
center-id: ${CENTER_ID:DC1}
gucci:
# 하위 코어(전문송수신) 관문 대상
chanel-base-url: ${CHANEL_URL:http://localhost:8091}
# JWT 서명 비밀키(운영은 외부 시크릿). 프로토타입 기본값.
jwt-secret: ${GUCCI_JWT_SECRET:rtgs-gucci-dev-secret-key-please-change-0123456789}
jwt-ttl-seconds: 1800
# 유량제어: subject(기관)별 window 내 허용 요청수
rate-limit-per-window: 50
rate-window-seconds: 10
# 재전송 방지: 허용 시각오차(초), nonce 보관 TTL(초)
replay-skew-seconds: 30
replay-nonce-ttl-seconds: 120
# G2 결과 콜백 송부: 폴링 주기(ms), 최대 재시도, 배치 크기
delivery-interval-ms: 3000
delivery-max-attempts: 5
delivery-batch: 50
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics

View File

@@ -0,0 +1,48 @@
package kr.or.bok.rtgs.gucci
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/** Gucci 관문 가드(유량제어·재전송·JWT) 결정론적 검증. */
class GucciGuardsTest {
@Test
fun `rate limiter allows up to limit per window then blocks, resets next window`() {
val rl = RateLimiter(limit = 3, windowSeconds = 10)
val t = 100_000L // 100초 시점(같은 10s 윈도우)
assertTrue(rl.allow("kookmin_s", t))
assertTrue(rl.allow("kookmin_s", t))
assertTrue(rl.allow("kookmin_s", t))
assertTrue(!rl.allow("kookmin_s", t)) // 4번째 초과 → 차단
assertTrue(rl.allow("shinhan_r", t)) // 다른 기관은 독립 버킷
assertTrue(rl.allow("kookmin_s", t + 10_000)) // 다음 윈도우 → 리셋
}
@Test
fun `replay guard rejects reuse, missing, and out-of-window`() {
val rg = ReplayGuard(skewSeconds = 30, nonceTtlSeconds = 120)
val nowMs = 1_000_000_000_000L
val nowSec = nowMs / 1000
assertTrue(rg.check("n1", nowSec, nowMs) is ReplayGuard.Result.Ok)
assertTrue(rg.check("n1", nowSec, nowMs) is ReplayGuard.Result.Reject) // 재사용
assertTrue(rg.check(null, nowSec, nowMs) is ReplayGuard.Result.Reject) // nonce 없음
assertTrue(rg.check("n2", nowSec - 60, nowMs) is ReplayGuard.Result.Reject) // 시각오차 초과
assertTrue(rg.check("n3", nowSec, nowMs) is ReplayGuard.Result.Ok)
}
@Test
fun `jwt round-trips, rejects tamper and expiry`() {
val jwt = JwtService(jacksonObjectMapper(), secret = "unit-test-secret-key-0123456789", ttlSeconds = 1800)
val now = 1_000_000_000_000L
val token = jwt.issue(Principal("kookmin_s", "ORG_S", "1001"), now)
val p = jwt.verify(token, now)
assertEquals("kookmin_s", p.subject)
assertEquals("ORG_S", p.role)
assertEquals("1001", p.orgCode)
assertFailsWith<JwtException> { jwt.verify(token + "x", now) } // 변조
assertFailsWith<JwtException> { jwt.verify(token, now + 1_801_000L) } // 만료(ttl 초과)
}
}

View File

@@ -0,0 +1,20 @@
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")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-web") // 메트릭/헬스 노출(B4)
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,67 @@
package kr.or.bok.rtgs.hermes
import kr.or.bok.rtgs.common.JournalEntry
import java.util.concurrent.ConcurrentSkipListMap
/**
* 저널 순차 게이트(B3) — globalSeq **오름차순**으로만 적용되도록 버퍼링/재정렬한다.
*
* rtgs.journal은 **단일 파티션**이라 Kafka가 offset(=순번기 발행) 순서대로 전달한다. 따라서
* "더 큰 seq가 도착했는데 `expected`는 안 온" 상황 = 그 `expected`가 **영영 발행되지 않은
* phantom**(B1의 SEQUENCE nextval 소비 후 미기록 등)이라는 의미다. 단일 파티션 순서 보장상
* 뒤늦게 채워질 일이 없으므로, 타임아웃 후 skip은 **실엔트리 스킵이 아니라** 결번 정리로 안전하다.
*
* 순수 로직(스프링 비의존) — 단위테스트로 규율 고정. Hermes 리스너(concurrency=1)와 타임아웃
* 스케줄러가 함께 접근하므로 메서드는 @Synchronized.
*/
class GapBuffer {
@Volatile
var expected: Long = 0L
private set
@Volatile
var lastAdvanceMillis: Long = 0L
private set
private val pending = ConcurrentSkipListMap<Long, JournalEntry>()
fun pendingCount(): Int = pending.size
fun highestPending(): Long? = if (pending.isEmpty()) null else pending.lastKey()
/** 도착 엔트리를 넣고, 지금 적용 가능한 엔트리들을 **순서대로** 반환(없으면 빈 리스트). */
@Synchronized
fun offer(entry: JournalEntry, nowMillis: Long): List<JournalEntry> {
if (expected == 0L) { // 첫 엔트리가 기대 순번을 앵커링
expected = entry.globalSeq
lastAdvanceMillis = nowMillis
}
return when {
entry.globalSeq < expected -> emptyList() // old/dup
entry.globalSeq > expected -> { pending[entry.globalSeq] = entry; emptyList() } // gap → 버퍼
else -> drainFrom(entry, nowMillis) // 기대 순번 → 적용 + 이어받기
}
}
/** expected(=phantom 확정)를 건너뛰고, 이어서 적용 가능한 엔트리들을 반환. */
@Synchronized
fun skipExpected(nowMillis: Long): List<JournalEntry> {
if (pending.isEmpty()) return emptyList() // 스킵할 이유 없음(대기자 없음)
expected++
val ready = mutableListOf<JournalEntry>()
while (pending.containsKey(expected)) { ready += pending.remove(expected)!!; expected++ }
if (ready.isNotEmpty()) lastAdvanceMillis = nowMillis
return ready
}
private fun drainFrom(head: JournalEntry, nowMillis: Long): List<JournalEntry> {
val ready = mutableListOf(head)
expected++
while (pending.containsKey(expected)) { ready += pending.remove(expected)!!; expected++ }
lastAdvanceMillis = nowMillis
return ready
}
/** pending이 있으면 마지막 진전 이후 경과(ms), 없으면 0(정체 아님). */
fun stalledMillis(nowMillis: Long): Long =
if (pending.isEmpty()) 0L else (nowMillis - lastAdvanceMillis).coerceAtLeast(0L)
}

View File

@@ -0,0 +1,71 @@
package kr.or.bok.rtgs.hermes
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.or.bok.rtgs.common.ledger.Account
import kr.or.bok.rtgs.common.ledger.JournalLog
import kr.or.bok.rtgs.common.ledger.TransferRecord
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.data.repository.query.Param
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EntityScan("kr.or.bok.rtgs.common.ledger")
@EnableJpaRepositories(considerNestedRepositories = true)
@EnableScheduling // B3 gap 타임아웃 스케줄러
class HermesApplication {
@Bean
fun objectMapper(): ObjectMapper = jacksonObjectMapper()
}
interface AccountRepository : JpaRepository<Account, String>
interface JournalLogRepository : JpaRepository<JournalLog, Long>
interface TransferRepository : JpaRepository<TransferRecord, String> {
/**
* 거래 원장 원자적 upsert. 신규면 INSERT, 이미 있으면(Dior/타 소비자가 먼저 생성) UPDATE.
* created_at은 최초 값 유지(갱신 제외). Dior-Hermes 동시 삽입 경합에서 중복키 예외 제거.
*/
@Modifying
@Query(
value = """
INSERT INTO transfer
(bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, created_at, updated_at, reason)
VALUES
(:bmi, :seq, :status, :sender, :receiver, :amount, :origin, :createdAt, :updatedAt, :reason)
ON CONFLICT (bmi) DO UPDATE SET
global_seq = EXCLUDED.global_seq,
status = EXCLUDED.status,
sender_code = EXCLUDED.sender_code,
receiver_code = EXCLUDED.receiver_code,
amount = EXCLUDED.amount,
origin_center = EXCLUDED.origin_center,
updated_at = EXCLUDED.updated_at,
reason = EXCLUDED.reason
""",
nativeQuery = true,
)
fun upsertTransfer(
@Param("bmi") bmi: String,
@Param("seq") seq: Long,
@Param("status") status: String,
@Param("sender") sender: String,
@Param("receiver") receiver: String,
@Param("amount") amount: Long,
@Param("origin") origin: String,
@Param("createdAt") createdAt: Long,
@Param("updatedAt") updatedAt: Long,
@Param("reason") reason: String?,
): Int
}
fun main(args: Array<String>) {
runApplication<HermesApplication>(*args)
}

View File

@@ -0,0 +1,83 @@
package kr.or.bok.rtgs.hermes
import com.fasterxml.jackson.databind.ObjectMapper
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import kr.or.bok.rtgs.common.JournalEntry
import kr.or.bok.rtgs.common.ResultMessage
import kr.or.bok.rtgs.common.Topics
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
/**
* 저널 순차 소비 + 순서 탐지·교정(복원력방안 2.3-부속, S4·B3). 실제 정산은 SettlementApplier(트랜잭션).
* **결과(rtgs.result) 발행은 정산 트랜잭션 커밋 이후**에 한다(publish-after-commit) — Prada가
* 커밋 전 transfer를 읽어 최종확정을 놓치는 레이스 방지. rtgs.result는 B2에서 applied-ack으로도 쓰인다.
*
* 순서 게이트는 GapBuffer(단위테스트로 규율 고정). 단일 파티션이라 정상 시 결번이 없고, 결번은
* 곧 phantom(발행 안 된 seq)이므로 **타임아웃 후 skip**으로 정체를 자가해소한다(B3).
*/
@Service
class HermesService(
private val applier: SettlementApplier,
private val kafka: KafkaTemplate<String, String>,
private val mapper: ObjectMapper,
private val metrics: MeterRegistry,
@Value("\${rtgs.gap-timeout-ms:5000}") private val gapTimeoutMs: Long,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val gate = GapBuffer()
// B4 메트릭: 정산 처리시간(TPS·지연) + gap 버퍼링 + gap 타임아웃(phantom skip)
private val settleTimer: Timer = Timer.builder("rtgs.settle").description("정산 처리시간").register(metrics)
private val gapCounter = metrics.counter("rtgs.journal.gap")
private val gapTimeoutCounter = metrics.counter("rtgs.journal.gap.timeout")
// 센터별 그룹(hermes-DC1/DC2/DC3)이라 각 센터가 저널 전량을 독립 재생.
@KafkaListener(topics = [Topics.JOURNAL], groupId = "hermes-\${rtgs.center-id:DC1}", concurrency = "1")
fun onJournal(payload: String) {
val entry = mapper.readValue(payload, JournalEntry::class.java)
val before = gate.pendingCount()
val ready = gate.offer(entry, System.currentTimeMillis())
if (ready.isEmpty() && gate.pendingCount() > before) {
gapCounter.increment()
log.warn("GAP: got seq={} expected={} -> buffered (pending={})", entry.globalSeq, gate.expected, gate.pendingCount())
}
ready.forEach { apply(it) }
}
/**
* B3 정체 감지: pending이 있고 마지막 진전 이후 gapTimeoutMs 초과면, expected는 phantom으로
* 확정(단일 파티션 순서 보장)하고 skip해 진행을 재개한다. 실엔트리 스킵이 아니라 결번 정리.
*/
@Scheduled(fixedDelayString = "\${rtgs.gap-check-ms:1000}")
fun checkGapTimeout() {
val now = System.currentTimeMillis()
if (gate.stalledMillis(now) < gapTimeoutMs) return
val missing = gate.expected
val ready = gate.skipExpected(now)
if (ready.isNotEmpty()) {
gapTimeoutCounter.increment()
log.error("GAP TIMEOUT: seq={} 미도착 {}ms 초과 → phantom 확정 skip, 재개(적용 {}건, 다음 expected={})",
missing, gapTimeoutMs, ready.size, gate.expected)
ready.forEach { apply(it) }
}
}
/** 정산 적용(시간 계측) 후 결과 발행. */
private fun apply(entry: JournalEntry) {
val result = settleTimer.recordCallable { applier.apply(entry) }
publishAfterCommit(result)
if (result != null) metrics.counter("rtgs.settlements", "status", result.status.name).increment()
}
/** 트랜잭션 커밋 후(apply 반환 = 커밋 완료) 결과 발행. null이면 멱등 스킵. */
private fun publishAfterCommit(result: ResultMessage?) {
if (result == null) return
kafka.send(Topics.RESULT, result.journalEntry.core.bmi, mapper.writeValueAsString(result))
}
}

View File

@@ -0,0 +1,74 @@
package kr.or.bok.rtgs.hermes
import kr.or.bok.rtgs.common.JournalEntry
import kr.or.bok.rtgs.common.ResultMessage
import kr.or.bok.rtgs.common.TxSts
import kr.or.bok.rtgs.common.ledger.JournalLog
import kr.or.bok.rtgs.common.ledger.TransferRecord
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 실제 정산을 강한 일관성 트랜잭션으로 수행. 한 저널 엔트리 = 한 트랜잭션.
* 선저널 → 잔액 이체 → transfer 상태확정. **결과 발행은 하지 않고 ResultMessage만 반환**한다
* (Kafka 발행은 호출측 HermesService가 트랜잭션 커밋 이후에 수행 — publish-after-commit).
* 이렇게 해야 Prada가 커밋 전 transfer를 읽는 레이스(ACSP 잔류)를 방지한다.
*/
@Service
class SettlementApplier(
private val accounts: AccountRepository,
private val transfers: TransferRepository,
private val journalLogs: JournalLogRepository,
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
) {
private val log = LoggerFactory.getLogger(javaClass)
/** @return 발행할 ResultMessage. 멱등 스킵이면 null. */
@Transactional
fun apply(e: JournalEntry): ResultMessage? {
val bmi = e.core.bmi
val existing = transfers.findById(bmi).orElse(null)
if (existing != null && (existing.status == TxSts.ACSP || existing.status == TxSts.ACCC)) return null // 멱등
if (!journalLogs.existsById(e.globalSeq)) {
journalLogs.save(JournalLog(globalSeq = e.globalSeq, bmi = bmi, seqEpochMillis = e.seqEpochMillis, applied = false))
}
val now = System.currentTimeMillis()
val sender = accounts.findById(e.core.senderCode).orElse(null)
val receiver = accounts.findById(e.core.receiverCode).orElse(null)
if (sender == null || receiver == null) {
return settle(e, TxSts.RJCT, now, null, null, "unknown account(s): ${e.core.senderCode}/${e.core.receiverCode}")
}
if (sender.balance < e.core.amount) {
return settle(e, TxSts.RJCT, now, sender.balance, receiver.balance, "insufficient balance")
}
// 이체(강한 일관성): 송신기관 당좌 차감 + 수신기관 당좌 가산
sender.balance -= e.core.amount
receiver.balance += e.core.amount
accounts.save(sender)
accounts.save(receiver)
val result = settle(e, TxSts.ACSP, now, sender.balance, receiver.balance, null)
log.info("ACSP seq=#{} bmi={} {}({})->{}({})", e.globalSeq, bmi,
e.core.senderCode, sender.balance, e.core.receiverCode, receiver.balance)
return result
}
private fun settle(e: JournalEntry, status: TxSts, now: Long, dBal: Long?, cBal: Long?, reason: String?): ResultMessage {
// 원자적 upsert(신규 INSERT / 기존 UPDATE) — Dior와의 동시 삽입 경합에서 중복키 예외 없음.
transfers.upsertTransfer(
bmi = e.core.bmi, seq = e.globalSeq, status = status.name,
sender = e.core.senderCode, receiver = e.core.receiverCode, amount = e.core.amount,
origin = e.originCenter, createdAt = e.seqEpochMillis, updatedAt = now, reason = reason,
)
journalLogs.findById(e.globalSeq).ifPresent { it.applied = true; journalLogs.save(it) }
return ResultMessage(
journalEntry = e, status = status, processedCenter = centerId,
debtorBalanceAfter = dBal, creditorBalanceAfter = cBal, reason = reason,
)
}
}

View File

@@ -0,0 +1,37 @@
spring:
application:
name: hermes
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: hermes
auto-offset-reset: earliest
enable-auto-commit: false
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
listener:
ack-mode: record
server:
port: 8093
rtgs:
center-id: ${CENTER_ID:DC1}
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics

View File

@@ -0,0 +1,80 @@
package kr.or.bok.rtgs.hermes
import kr.or.bok.rtgs.common.CoreMessage
import kr.or.bok.rtgs.common.JournalEntry
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/** B3 순차 게이트(버퍼링·재정렬·phantom skip) 규율 고정 테스트. */
class GapBufferTest {
private fun entry(seq: Long): JournalEntry = JournalEntry(
globalSeq = seq,
seqEpochMillis = 1_000L + seq,
originCenter = "DC1",
core = CoreMessage(
bmi = "bmi%022d".format(seq), msgType = "pacs.008.001.08",
senderCode = "1001", receiverCode = "1002",
debtorAcct = "1001", creditorAcct = "1002",
amount = 100, origHash = "h",
),
)
private fun seqs(list: List<JournalEntry>) = list.map { it.globalSeq }
@Test
fun `순서대로 오면 즉시 적용`() {
val g = GapBuffer()
assertEquals(listOf(1L), seqs(g.offer(entry(1), 10)))
assertEquals(listOf(2L), seqs(g.offer(entry(2), 11)))
assertEquals(3L, g.expected)
}
@Test
fun `역전 도착은 버퍼링 후 결번 채워지면 한꺼번에 방출`() {
val g = GapBuffer()
assertEquals(listOf(1L), seqs(g.offer(entry(1), 10)))
assertEquals(emptyList<Long>(), seqs(g.offer(entry(3), 11))) // gap: 2 미도착 → 버퍼
assertEquals(emptyList<Long>(), seqs(g.offer(entry(4), 12))) // 버퍼
assertEquals(2, g.pendingCount())
assertEquals(listOf(2L, 3L, 4L), seqs(g.offer(entry(2), 13))) // 2 도착 → 2,3,4 연속 방출
assertEquals(5L, g.expected)
assertEquals(0, g.pendingCount())
}
@Test
fun `과거 중복 seq는 무시`() {
val g = GapBuffer()
g.offer(entry(1), 10); g.offer(entry(2), 11)
assertEquals(emptyList<Long>(), seqs(g.offer(entry(1), 12))) // 이미 지난 seq
assertEquals(3L, g.expected)
}
@Test
fun `첫 엔트리가 중간 seq여도 그 값으로 앵커링`() {
val g = GapBuffer()
assertEquals(listOf(5L), seqs(g.offer(entry(5), 10))) // 재기동 후 earliest=5
assertEquals(6L, g.expected)
}
@Test
fun `정체 감지 phantom skip으로 진행 재개`() {
val g = GapBuffer()
g.offer(entry(1), 100) // 적용, expected=2, lastAdvance=100
g.offer(entry(3), 101) // 2 결번 → 3 버퍼
assertEquals(50L, g.stalledMillis(150)) // 150-100 정체
// 타임아웃: 2는 phantom → skip → 3 방출, expected=4
assertEquals(listOf(3L), seqs(g.skipExpected(160)))
assertEquals(4L, g.expected)
assertEquals(0, g.stalledMillis(200)) // 대기자 없음 → 정체 아님
}
@Test
fun `대기자 없으면 정체 0 이고 skip은 무동작`() {
val g = GapBuffer()
g.offer(entry(1), 10)
assertEquals(0L, g.stalledMillis(9999))
assertEquals(emptyList<Long>(), seqs(g.skipExpected(9999)))
assertEquals(2L, g.expected) // 변화 없음
}
}

View File

@@ -0,0 +1,22 @@
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
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.flywaydb:flyway-core") // DB 스키마 형상관리(단일 출처)
implementation("org.flywaydb:flyway-database-postgresql") // Flyway 10 PostgreSQL 모듈
implementation("org.springframework.security:spring-security-crypto") // BCrypt(관리자 비번 변경)
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,130 @@
package kr.or.bok.rtgs.louisvuitton
import kr.or.bok.rtgs.common.ledger.Account
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.web.bind.annotation.DeleteMapping
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.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.transaction.annotation.Transactional
data class InstitutionReq(val code: String, val name: String, val balance: Long = 1_000_000_000)
data class UserReq(val username: String, val displayName: String = "", val role: String = "ORG_S", val orgCode: String? = null)
/**
* 관리자(Louis Vuitton) API. 인증 강제는 후속 단계이므로 현재는 관리 마스터/운영 기능만.
*/
@RestController
class AdminController(
private val jdbc: JdbcTemplate,
private val accounts: AccountRepository,
private val users: AppUserRepository,
) {
private val log = LoggerFactory.getLogger(javaClass)
// ---------- 1) 테스트용 DB 초기화 ----------
@PostMapping("/admin/reset")
@Transactional
fun reset(@RequestParam(defaultValue = "1000000000") initialBalance: Long): Map<String, Any?> {
jdbc.execute("TRUNCATE transfer, journal_log, settlement_view, raw_message, notification")
val updated = jdbc.update("UPDATE account SET balance = ?", initialBalance)
log.warn("ADMIN RESET: cleared ledger tables, reset {} accounts to {}", updated, initialBalance)
return mapOf("ok" to true, "accountsReset" to updated, "initialBalance" to initialBalance,
"cleared" to listOf("transfer", "journal_log", "settlement_view", "raw_message", "notification"),
"note" to "Kafka 저널 이력은 보존(오프셋 커밋으로 재처리 안 함). DB만 초기화.")
}
// ---------- 2) 코드 관리: 참가기관(당좌계좌) 마스터 ----------
@GetMapping("/admin/institutions")
fun institutions() = accounts.findAll().sortedBy { it.code }
.map { mapOf("code" to it.code, "name" to it.name, "balance" to it.balance) }
@PostMapping("/admin/institutions")
fun upsertInstitution(@RequestBody r: InstitutionReq): Map<String, Any?> {
require(r.code.matches(Regex("[0-9]{4}"))) { "기관코드는 숫자 4자리" }
accounts.save(Account(code = r.code, name = r.name, balance = r.balance))
return mapOf("ok" to true, "code" to r.code)
}
@DeleteMapping("/admin/institutions/{code}")
fun deleteInstitution(@PathVariable code: String): Map<String, Any?> {
accounts.deleteById(code)
return mapOf("ok" to true, "deleted" to code)
}
/** 처리상태 코드 데이터 사전. */
@GetMapping("/admin/status-codes")
fun statusCodes() = listOf(
mapOf("code" to "RCVD", "name" to "접수", "desc" to "Received - 접수됨"),
mapOf("code" to "ACTC", "name" to "승인", "desc" to "AcceptedTechnicalValidation - 검증 통과"),
mapOf("code" to "PDNG", "name" to "대기", "desc" to "Pending - 타 센터 수신/결제 전"),
mapOf("code" to "ACSP", "name" to "예약", "desc" to "AcceptedSettlementInProcess - 결제 반영, 결과 대기"),
mapOf("code" to "ACCC", "name" to "입금처리완료", "desc" to "AcceptedSettlementCompleted - 최종 완결"),
mapOf("code" to "RJCT", "name" to "반려", "desc" to "Rejected - 검증 실패/잔액부족 등"),
)
// ---------- 3) 사용자 권한 관리 (관리 마스터) ----------
@GetMapping("/admin/users")
fun listUsers() = users.findAll().sortedBy { it.username }
.map { mapOf("username" to it.username, "displayName" to it.displayName, "role" to it.role, "orgCode" to it.orgCode) }
@PostMapping("/admin/users")
fun upsertUser(@RequestBody r: UserReq): Map<String, Any?> {
require(r.role in setOf("ADMIN", "ORG_S", "ORG_R")) { "role은 ADMIN/ORG_S/ORG_R" }
users.save(AppUser(username = r.username, displayName = r.displayName, role = r.role,
orgCode = r.orgCode, createdAt = System.currentTimeMillis()))
return mapOf("ok" to true, "username" to r.username)
}
@DeleteMapping("/admin/users/{username}")
fun deleteUser(@PathVariable username: String): Map<String, Any?> {
users.deleteById(username)
return mapOf("ok" to true, "deleted" to username)
}
// ---------- 4) 대사(정합성) 대시보드 ----------
@GetMapping("/admin/summary")
fun summary(): Map<String, Any?> = mapOf(
"byStatus" to jdbc.queryForList("SELECT status, count(*) AS cnt FROM transfer GROUP BY status ORDER BY status"),
"totalBalance" to jdbc.queryForObject("SELECT COALESCE(sum(balance),0) FROM account", Long::class.java),
"accounts" to jdbc.queryForObject("SELECT count(*) FROM account", Long::class.java),
"transfers" to jdbc.queryForObject("SELECT count(*) FROM transfer", Long::class.java),
"maxSeq" to jdbc.queryForObject("SELECT COALESCE(max(global_seq),0) FROM transfer", Long::class.java),
)
// ---------- 5) 서비스 헬스(B5) — 하트비트 기반 liveness(헤드리스 포함) ----------
@GetMapping("/admin/health")
fun health(): List<Map<String, Any?>> {
val now = System.currentTimeMillis()
return jdbc.queryForList("SELECT service, center, last_seen, pid FROM service_heartbeat ORDER BY service")
.map { r ->
val last = (r["last_seen"] as? Number)?.toLong() ?: 0
val ageMs = now - last
mapOf(
"service" to r["service"], "center" to r["center"], "pid" to r["pid"],
"lastSeen" to last, "ageMs" to ageMs,
"status" to if (ageMs <= 15000) "UP" else "STALE",
)
}
}
private fun lim(n: Int) = n.coerceIn(1, 200).toString()
@GetMapping("/admin/ledger/transfers")
fun transfers(@RequestParam(defaultValue = "20") limit: Int) = jdbc.queryForList(
"SELECT bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, updated_at " +
"FROM transfer ORDER BY global_seq DESC NULLS LAST LIMIT ${lim(limit)}")
@GetMapping("/admin/ledger/journal")
fun journal(@RequestParam(defaultValue = "20") limit: Int) = jdbc.queryForList(
"SELECT global_seq, bmi, applied FROM journal_log ORDER BY global_seq DESC LIMIT ${lim(limit)}")
@GetMapping("/admin/ledger/views")
fun views(@RequestParam(defaultValue = "20") limit: Int) = jdbc.queryForList(
"SELECT bmi, global_seq, final_status, debtor_balance_after, creditor_balance_after, finalized_at " +
"FROM settlement_view ORDER BY global_seq DESC NULLS LAST LIMIT ${lim(limit)}")
}

View File

@@ -0,0 +1,79 @@
package kr.or.bok.rtgs.louisvuitton
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import java.nio.charset.StandardCharsets
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* 관리자 API 보호 — Gucci가 발급한 JWT를 검증해 role=ADMIN만 admin 경로 실행 허용(A1).
* 검증만 수행하므로 Gucci와 동일한 서명키(gucci.jwt-secret)만 공유하면 된다(발급 코드 불필요).
*/
@Component
class AdminJwt(
private val mapper: ObjectMapper,
@Value("\${gucci.jwt-secret}") private val secret: String,
) {
private val dec = Base64.getUrlDecoder()
private val enc = Base64.getUrlEncoder().withoutPadding()
/** 유효하고 role=ADMIN이면 사용자명 반환, 아니면 null. */
fun adminSubjectOrNull(authz: String?, nowMs: Long): String? {
val token = authz?.removePrefix("Bearer ")?.trim() ?: return null
val p = token.split(".")
if (p.size != 3) return null
val expected = sign("${p[0]}.${p[1]}")
if (!constantTimeEquals(expected, p[2])) return null
val claims: Map<*, *> = runCatching { mapper.readValue(dec.decode(p[1]), Map::class.java) }.getOrNull() ?: return null
val exp = (claims["exp"] as? Number)?.toLong() ?: return null
if (nowMs / 1000 > exp) return null
if ((claims["role"] as? String) != "ADMIN") return null
return claims["sub"] as? String
}
private fun sign(input: String): String {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256"))
return enc.encodeToString(mac.doFinal(input.toByteArray(StandardCharsets.UTF_8)))
}
private fun constantTimeEquals(a: String, b: String): Boolean {
val x = a.toByteArray(); val y = b.toByteArray()
if (x.size != y.size) return false
var r = 0; for (i in x.indices) r = r or (x[i].toInt() xor y[i].toInt()); return r == 0
}
}
@Component
class AdminAuthInterceptor(private val adminJwt: AdminJwt) : HandlerInterceptor {
private val log = LoggerFactory.getLogger(javaClass)
override fun preHandle(req: HttpServletRequest, res: HttpServletResponse, handler: Any): Boolean {
val subject = adminJwt.adminSubjectOrNull(req.getHeader("Authorization"), System.currentTimeMillis())
if (subject == null) {
log.warn("ADMIN deny path={} (no/invalid ADMIN token)", req.requestURI)
res.status = HttpServletResponse.SC_UNAUTHORIZED
res.contentType = "application/json;charset=UTF-8"
res.writer.write("""{"error":"admin authentication required"}""")
return false
}
return true
}
}
/** admin 경로에 관리자 인증 인터셉터 적용. */
@Configuration
class AdminWebConfig(private val interceptor: AdminAuthInterceptor) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(interceptor).addPathPatterns("/admin/**")
}
}

View File

@@ -0,0 +1,54 @@
package kr.or.bok.rtgs.louisvuitton
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* 부하테스트 콘솔 API (인증 없는 `/console` 경로 — AdminAuthInterceptor는 `/admin` 경로만 보호).
* 정적 화면(static/loadtest.html)이 이 엔드포인트를 호출한다. localhost 프로토타입 운영도구.
*/
@RestController
@RequestMapping("/console")
class LoadTestController(
private val loadTest: LoadTestService,
private val jdbc: JdbcTemplate,
) {
@PostMapping("/loadtest/start")
fun start(@RequestBody params: LoadTestParams): ResponseEntity<Map<String, Any?>> {
return try {
if (loadTest.start(params)) ResponseEntity.accepted().body(loadTest.status())
else ResponseEntity.status(HttpStatus.CONFLICT)
.body(mapOf("error" to "이미 실행 중인 부하테스트가 있습니다."))
} catch (e: IllegalStateException) {
ResponseEntity.badRequest().body(mapOf("error" to (e.message ?: "잘못된 요청")))
}
}
@GetMapping("/loadtest/status")
fun status(): Map<String, Any?> = loadTest.status()
@PostMapping("/loadtest/stop")
fun stop(): Map<String, Any?> {
loadTest.stop()
return mapOf("ok" to true, "running" to loadTest.isRunning)
}
@GetMapping("/loadtest/result")
fun result(): Map<String, Any?> = loadTest.result()
/** 원장 집계(읽기 전용) — 부하 전/후 스냅샷으로 총잔액 보존(0-합) 표시용. */
@GetMapping("/ledger/summary")
fun ledgerSummary(): Map<String, Any?> = mapOf(
"byStatus" to jdbc.queryForList("SELECT status, count(*) AS cnt FROM transfer GROUP BY status ORDER BY status"),
"totalBalance" to jdbc.queryForObject("SELECT COALESCE(sum(balance),0) FROM account", Long::class.java),
"accounts" to jdbc.queryForObject("SELECT count(*) FROM account", Long::class.java),
"transfers" to jdbc.queryForObject("SELECT count(*) FROM transfer", Long::class.java),
"maxSeq" to jdbc.queryForObject("SELECT COALESCE(max(global_seq),0) FROM transfer", Long::class.java),
)
}

View File

@@ -0,0 +1,222 @@
package kr.or.bok.rtgs.louisvuitton
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Executors
import java.util.concurrent.ThreadLocalRandom
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
/** 콘솔 부하테스트 요청 파라미터(동시 요청 수 기반 closed-loop). */
data class LoadTestParams(
val concurrency: Int = 10,
val durationSec: Int = 15,
val chanelUrl: String = "http://localhost:8091",
val amountMin: Long = 1,
val amountMax: Long = 200,
)
/**
* 루이비똥 내장 부하생성기 — Chanel `/pay/customer`에 pacs.008.001.08 전문을 실시간 생성·투입.
*
* 동시 요청 수 기반 closed-loop: 가상 스레드 워커 N개가 각자 완료 즉시 다음 건을 보내며 D초간 반복.
* 외부 k6 의존 없이 서비스 안에서 백엔드와 동일한 전문 포맷을 쓴다(포맷 어긋남 방지).
* 한 번에 하나의 테스트만 수행한다(running 가드).
*/
@Service
class LoadTestService(private val accounts: AccountRepository) {
private val log = LoggerFactory.getLogger(javaClass)
private val http: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build()
// ---- 실행 상태 ----
private val running = AtomicBoolean(false)
@Volatile private var params: LoadTestParams = LoadTestParams()
@Volatile private var startMs: Long = 0
@Volatile private var endMs: Long = 0
@Volatile private var deadline: Long = 0
@Volatile private var codes: List<String> = emptyList()
private val sent = AtomicLong(0)
private val accepted = AtomicLong(0)
private val rejected = AtomicLong(0)
private val errors = AtomicLong(0)
private val latSumMs = AtomicLong(0)
private val latencies = ConcurrentLinkedQueue<Long>() // ms 표본
// BMI 일련번호: JVM 시작 시각으로 시드 → 재기동/여러 run에도 사실상 유일.
private val serialSeq = AtomicLong(System.currentTimeMillis() % 10_000_000_000L)
private val bizDate: String = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
val isRunning: Boolean get() = running.get()
/** 테스트 시작. 이미 실행 중이면 false. */
fun start(p: LoadTestParams): Boolean {
if (!running.compareAndSet(false, true)) return false
val codeList = accounts.findAll().map { it.code }.filter { it.isNotBlank() }.sorted()
if (codeList.size < 2) {
running.set(false)
throw IllegalStateException("참가기관 계좌가 2개 미만입니다(현재 ${codeList.size}). 부하 생성 불가.")
}
params = p
codes = codeList
sent.set(0); accepted.set(0); rejected.set(0); errors.set(0); latSumMs.set(0)
latencies.clear()
startMs = System.currentTimeMillis()
endMs = 0
deadline = startMs + p.durationSec.coerceIn(1, 3600) * 1000L
val workerCount = p.concurrency.coerceIn(1, 2000)
// 코디네이터(데몬) — 워커 완료까지 대기 후 종료 마킹. start()는 즉시 반환.
Thread {
val exec = Executors.newVirtualThreadPerTaskExecutor()
try {
val tasks = (1..workerCount).map { exec.submit { worker() } }
tasks.forEach { runCatching { it.get() } }
} finally {
exec.shutdown()
endMs = System.currentTimeMillis()
running.set(false)
log.info("[loadtest] done: sent={} accepted={} rejected={} errors={} in {}ms",
sent.get(), accepted.get(), rejected.get(), errors.get(), endMs - startMs)
}
}.apply { isDaemon = true; name = "loadtest-coordinator" }.start()
log.warn("[loadtest] start: concurrency={} durationSec={} target={} amount={}~{}",
workerCount, p.durationSec, p.chanelUrl, p.amountMin, p.amountMax)
return true
}
/** 실행 중 워커를 즉시 종료(마감을 현재로 당김). */
fun stop() {
if (running.get()) {
deadline = System.currentTimeMillis()
log.warn("[loadtest] stop requested")
}
}
private fun worker() {
val rnd = ThreadLocalRandom.current()
val url = URI.create(params.chanelUrl.trimEnd('/') + "/pay/customer")
val lo = params.amountMin
val span = (params.amountMax - params.amountMin + 1).coerceAtLeast(1)
while (System.currentTimeMillis() < deadline) {
val s = codes[rnd.nextInt(codes.size)]
var r = codes[rnd.nextInt(codes.size)]
while (r == s) r = codes[rnd.nextInt(codes.size)]
val amount = lo + (rnd.nextLong(span))
val xml = buildPacs008(s, r, amount)
val req = HttpRequest.newBuilder(url)
.timeout(Duration.ofSeconds(30))
.header("Content-Type", "application/xml")
.POST(HttpRequest.BodyPublishers.ofString(xml))
.build()
val t0 = System.nanoTime()
try {
val resp = http.send(req, HttpResponse.BodyHandlers.ofString())
record(System.nanoTime() - t0)
when {
resp.statusCode() == 200 && resp.body().contains("RCVD") -> accepted.incrementAndGet()
resp.statusCode() == 200 -> rejected.incrementAndGet()
else -> errors.incrementAndGet()
}
} catch (e: Exception) {
record(System.nanoTime() - t0)
errors.incrementAndGet()
}
}
}
private fun record(nanos: Long) {
val ms = nanos / 1_000_000
sent.incrementAndGet()
latSumMs.addAndGet(ms)
latencies.add(ms)
}
/** BMI = 날짜(8) + 송신기관(4) + 일련번호(10) = 22자리. */
private fun buildPacs008(s: String, r: String, amount: Long): String {
val serial = (serialSeq.incrementAndGet() % 10_000_000_000L).toString().padStart(10, '0')
val bmi = bizDate + s + serial
val creDtTm = java.time.LocalDateTime.now().withNano(0)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08\">" +
"<FIToFICstmrCdtTrf>" +
"<GrpHdr><MsgId>$bmi</MsgId><CreDtTm>$creDtTm</CreDtTm><NbOfTxs>1</NbOfTxs>" +
"<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr>" +
"<CdtTrfTxInf>" +
"<PmtId><EndToEndId>$bmi</EndToEndId></PmtId>" +
"<IntrBkSttlmAmt Ccy=\"KRW\">$amount</IntrBkSttlmAmt>" +
"<ChrgBr>SLEV</ChrgBr>" +
"<Dbtr><Nm>BANK-$s</Nm></Dbtr>" +
"<DbtrAcct><Id><Othr><Id>ACC-$s</Id></Othr></Id></DbtrAcct>" +
"<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>$s</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>" +
"<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>$r</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>" +
"<Cdtr><Nm>BANK-$r</Nm></Cdtr>" +
"<CdtrAcct><Id><Othr><Id>ACC-$r</Id></Othr></Id></CdtrAcct>" +
"</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>"
}
/** 실시간 스냅샷(진행 중/후 모두). */
fun status(): Map<String, Any?> {
val now = if (running.get()) System.currentTimeMillis() else endMs.takeIf { it > 0 } ?: System.currentTimeMillis()
val elapsedMs = (now - startMs).coerceAtLeast(0)
val elapsedSec = elapsedMs / 1000.0
val sentN = sent.get()
return mapOf(
"running" to running.get(),
"params" to params,
"elapsedMs" to elapsedMs,
"durationSec" to params.durationSec,
"sent" to sentN,
"accepted" to accepted.get(),
"rejected" to rejected.get(),
"errors" to errors.get(),
"tps" to if (elapsedSec > 0) round2(sentN / elapsedSec) else 0.0,
"avgLatencyMs" to if (sentN > 0) latSumMs.get() / sentN else 0,
)
}
/** 최종 요약(백분위 포함). 실행 중이면 그 시점까지의 값. */
fun result(): Map<String, Any?> {
val sentN = sent.get()
val acc = accepted.get()
val end = if (endMs > 0) endMs else System.currentTimeMillis()
val durMs = (end - startMs).coerceAtLeast(1)
val durSec = durMs / 1000.0
val arr = latencies.toLongArray().also { it.sort() }
fun pct(p: Double): Long = if (arr.isEmpty()) 0 else arr[((p / 100.0 * arr.size).toInt()).coerceIn(0, arr.size - 1)]
return mapOf(
"running" to running.get(),
"params" to params,
"durationMs" to durMs,
"sent" to sentN,
"accepted" to acc,
"rejected" to rejected.get(),
"errors" to errors.get(),
"successRate" to if (sentN > 0) round2(acc * 100.0 / sentN) else 0.0,
"throughputTps" to if (durSec > 0) round2(sentN / durSec) else 0.0,
"latencyMs" to mapOf(
"min" to (arr.firstOrNull() ?: 0),
"avg" to if (sentN > 0) latSumMs.get() / sentN else 0,
"p50" to pct(50.0), "p90" to pct(90.0), "p95" to pct(95.0),
"p99" to pct(99.0), "max" to (arr.lastOrNull() ?: 0),
),
)
}
private fun round2(v: Double): Double = Math.round(v * 100.0) / 100.0
}

View File

@@ -0,0 +1,39 @@
package kr.or.bok.rtgs.louisvuitton
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import kr.or.bok.rtgs.common.ledger.Account
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
/**
* LUIS VUITTON (시스템관리) — 관리자 기능: DB 초기화, 코드(참가기관) 관리, 사용자 권한 관리, 대사.
* (3차 PoC 미구현분. 인증/로그인 강제는 후속 단계; 지금은 사용자·권한 "관리 마스터"만.)
*/
@SpringBootApplication
@EntityScan(basePackages = ["kr.or.bok.rtgs.common.ledger", "kr.or.bok.rtgs.louisvuitton"])
@EnableJpaRepositories(considerNestedRepositories = true, basePackages = ["kr.or.bok.rtgs.louisvuitton"])
class LouisVuittonApplication
/** 사용자·권한 관리 마스터. role = ADMIN / ORG_S(송신기관) / ORG_R(수신기관). */
@Entity
@Table(name = "app_user")
class AppUser(
@Id @Column(name = "username", length = 32) var username: String = "",
@Column(name = "display_name", length = 64) var displayName: String = "",
@Column(name = "role", length = 16) var role: String = "ORG_S",
@Column(name = "org_code", length = 4) var orgCode: String? = null,
@Column(name = "created_at") var createdAt: Long = 0,
)
interface AppUserRepository : JpaRepository<AppUser, String>
interface AccountRepository : JpaRepository<Account, String>
fun main(args: Array<String>) {
runApplication<LouisVuittonApplication>(*args)
}

View File

@@ -0,0 +1,31 @@
spring:
application:
name: louisvuitton
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
flyway:
enabled: true
baseline-on-migrate: true # 기존(비어있지 않은) 운영 DB는 V1을 baseline 처리(무변경)
baseline-version: 1
locations: classpath:db/migration
server:
port: 8099
rtgs:
center-id: ${CENTER_ID:DC1}
gucci:
jwt-secret: ${GUCCI_JWT_SECRET:rtgs-gucci-dev-secret-key-please-change-0123456789} # Gucci와 동일 키로 토큰 검증
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics

View File

@@ -0,0 +1,161 @@
-- RTGS 권위 원장 스키마 (PostgreSQL, 강한 일관성). 엔티티: common/ledger/Entities.kt 와 정합.
-- ddl-auto=none 이므로 이 스크립트가 스키마를 생성한다(컨테이너 최초 기동 시 1회).
CREATE TABLE IF NOT EXISTS account (
code VARCHAR(4) PRIMARY KEY,
name VARCHAR(64) NOT NULL,
balance BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS transfer (
bmi VARCHAR(22) PRIMARY KEY,
global_seq BIGINT,
status VARCHAR(8) NOT NULL,
sender_code VARCHAR(4),
receiver_code VARCHAR(4),
amount BIGINT NOT NULL,
origin_center VARCHAR(8),
created_at BIGINT,
updated_at BIGINT,
reason VARCHAR(255)
);
CREATE INDEX IF NOT EXISTS idx_transfer_seq ON transfer (global_seq);
CREATE INDEX IF NOT EXISTS idx_transfer_status ON transfer (status);
-- 선(先)저널(write-ahead) 로그: 무손실 승계·감사
CREATE TABLE IF NOT EXISTS journal_log (
global_seq BIGINT PRIMARY KEY,
bmi VARCHAR(22),
seq_epoch_millis BIGINT,
applied BOOLEAN NOT NULL DEFAULT FALSE
);
-- 원전문 저장 (로컬 PostgreSQL text; 클라우드/BMT는 문서형 DB(MongoDB)).
CREATE TABLE IF NOT EXISTS raw_message (
bmi VARCHAR(22) PRIMARY KEY,
msg_type VARCHAR(32),
raw_xml TEXT,
orig_hash VARCHAR(64),
received_at BIGINT,
origin_center VARCHAR(8)
);
-- 조회 전용 사본 (로컬 PostgreSQL; 클라우드/BMT는 문서형 DB).
CREATE TABLE IF NOT EXISTS settlement_view (
bmi VARCHAR(22) PRIMARY KEY,
global_seq BIGINT,
final_status VARCHAR(8),
sender_code VARCHAR(4),
receiver_code VARCHAR(4),
amount BIGINT,
origin_center VARCHAR(8),
processed_center VARCHAR(8),
debtor_balance_after BIGINT,
creditor_balance_after BIGINT,
finalized_at BIGINT
);
-- 결과통보 아웃박스 — 접수센터(Chanel)가 신청/수취기관 앞 송부한 결과 전문(pacs.002).
CREATE TABLE IF NOT EXISTS notification (
id VARCHAR(32) PRIMARY KEY, -- bmi:수신기관
bmi VARCHAR(22),
to_org VARCHAR(4), -- 수신 대상 기관
role VARCHAR(12), -- APPLICANT(신청) / BENEFICIARY(수취)
final_status VARCHAR(8),
pacs002_xml TEXT,
origin_center VARCHAR(8),
reason TEXT,
delivered BOOLEAN NOT NULL DEFAULT FALSE,
attempts INTEGER NOT NULL DEFAULT 0,
created_at BIGINT,
delivered_at BIGINT
);
ALTER TABLE notification ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_notification_bmi ON notification (bmi);
CREATE INDEX IF NOT EXISTS idx_notification_pending ON notification (delivered);
-- Gucci 콜백 레지스트리 — 기관별 결과통보 수신 URL. Gucci(관문)가 pacs.002를 여기로 송부(재시도/ACK).
-- 로컬 데모는 Gucci 자체 sink(:8095/gucci/sink/{org})로 자기수신해 왕복을 시연.
CREATE TABLE IF NOT EXISTS institution_endpoint (
org_code VARCHAR(4) PRIMARY KEY,
callback_url VARCHAR(256),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at BIGINT
);
INSERT INTO institution_endpoint(org_code, callback_url, active, created_at) VALUES
('1001','http://localhost:8095/gucci/sink/1001',TRUE,0),
('1002','http://localhost:8095/gucci/sink/1002',TRUE,0),
('1008','http://localhost:8095/gucci/sink/1008',TRUE,0),
('1010','http://localhost:8095/gucci/sink/1010',TRUE,0)
ON CONFLICT (org_code) DO NOTHING;
COMMENT ON COLUMN notification.bmi IS '거래식별자(BMI)';
COMMENT ON COLUMN notification.to_org IS '통보대상기관';
COMMENT ON COLUMN notification.role IS '대상역할(신청/수취)';
COMMENT ON COLUMN notification.final_status IS '최종상태';
COMMENT ON COLUMN notification.pacs002_xml IS '결과전문(pacs.002)';
COMMENT ON COLUMN notification.delivered IS '송부여부';
COMMENT ON COLUMN notification.created_at IS '생성시각';
-- 참가기관 당좌계좌 시드 (19개). 초기 잔액 10억원(부하테스트 1~200원 이체가 고갈되지 않도록).
INSERT INTO account (code, name, balance) VALUES
('1001', 'KOOKMIN', 1000000000),
('1002', 'SHINHAN', 1000000000),
('1003', 'WOORI', 1000000000),
('1004', 'HANA', 1000000000),
('1005', 'NONGHYUP', 1000000000),
('1006', 'IBK', 1000000000),
('1007', 'SC', 1000000000),
('1008', 'CITI', 1000000000),
('1009', 'KDB', 1000000000),
('1010', 'SUHYUP', 1000000000),
('1011', 'DGB', 1000000000),
('1012', 'BNK_BUSAN', 1000000000),
('1013', 'BNK_KYONGNAM', 1000000000),
('1014', 'KWANGJU', 1000000000),
('1015', 'JEONBUK', 1000000000),
('1016', 'JEJU', 1000000000),
('1017', 'KAKAOBANK', 1000000000),
('1018', 'KBANK', 1000000000),
('1019', 'TOSSBANK', 1000000000)
ON CONFLICT (code) DO NOTHING;
-- 데이터 사전: 컬럼 한글명(코멘트) — 화면 라벨의 단일 출처
COMMENT ON COLUMN transfer.bmi IS '거래식별자(BMI)';
COMMENT ON COLUMN transfer.global_seq IS '전역순번';
COMMENT ON COLUMN transfer.status IS '처리상태';
COMMENT ON COLUMN transfer.sender_code IS '송신기관코드';
COMMENT ON COLUMN transfer.receiver_code IS '수신기관코드';
COMMENT ON COLUMN transfer.amount IS '이체금액(원)';
COMMENT ON COLUMN transfer.origin_center IS '접수센터';
COMMENT ON COLUMN transfer.created_at IS '접수시각';
COMMENT ON COLUMN transfer.updated_at IS '최종수정시각';
COMMENT ON COLUMN transfer.reason IS '사유';
COMMENT ON COLUMN account.code IS '기관코드';
COMMENT ON COLUMN account.name IS '기관명';
COMMENT ON COLUMN account.balance IS '당좌계좌잔액(원)';
-- 사용자·권한 관리 마스터(Louis Vuitton). role: ADMIN/ORG_S/ORG_R. (로그인 강제는 후속)
CREATE TABLE IF NOT EXISTS app_user (
username VARCHAR(32) PRIMARY KEY,
display_name VARCHAR(64),
role VARCHAR(16) NOT NULL,
org_code VARCHAR(4),
secret VARCHAR(128), -- Gucci 로그인 자격증명. 시드는 평문 dev값, 기동 시 Gucci가 BCrypt 해시로 승격
created_at BIGINT
);
-- 기존 DB 호환: secret 컬럼 추가(있으면 무시)
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS secret VARCHAR(128);
COMMENT ON COLUMN app_user.username IS '사용자ID';
COMMENT ON COLUMN app_user.display_name IS '사용자명';
COMMENT ON COLUMN app_user.role IS '역할(ADMIN/ORG_S/ORG_R)';
COMMENT ON COLUMN app_user.org_code IS '매핑기관코드';
INSERT INTO app_user(username,display_name,role,org_code,secret,created_at) VALUES
('admin','시스템관리자','ADMIN',NULL,'admin-secret',0),
('kookmin_s','국민은행 송신담당','ORG_S','1001','kookmin-secret',0),
('shinhan_r','신한은행 수신담당','ORG_R','1002','shinhan-secret',0)
ON CONFLICT (username) DO NOTHING;
-- 기존 행에 dev 시크릿 보정(널인 경우만)
UPDATE app_user SET secret = 'admin-secret' WHERE username='admin' AND secret IS NULL;
UPDATE app_user SET secret = 'kookmin-secret' WHERE username='kookmin_s' AND secret IS NULL;
UPDATE app_user SET secret = 'shinhan-secret' WHERE username='shinhan_r' AND secret IS NULL;
COMMENT ON COLUMN app_user.secret IS 'Gucci로그인비밀키';

View File

@@ -0,0 +1,8 @@
-- A1 관리자 로그인: 초기암호 변경 플래그 + 신속 테스트 관리자(a/1)
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN app_user.must_change_password IS '초기암호변경필요';
-- 신속 테스트용 관리자 a / 1 (secret 평문 → Gucci 기동 시 BCrypt 해시로 승격)
INSERT INTO app_user(username, display_name, role, org_code, secret, must_change_password, created_at)
VALUES ('a', '테스트관리자', 'ADMIN', NULL, '1', FALSE, 0)
ON CONFLICT (username) DO NOTHING;

View File

@@ -0,0 +1,12 @@
-- B5 서비스 하트비트 — 각 서비스가 주기적으로 생존 신호 기록(헤드리스 포함 liveness를 DB로 확인)
CREATE TABLE IF NOT EXISTS service_heartbeat (
service VARCHAR(32),
center VARCHAR(8),
last_seen BIGINT,
pid BIGINT,
PRIMARY KEY (service, center)
);
COMMENT ON COLUMN service_heartbeat.service IS '서비스명';
COMMENT ON COLUMN service_heartbeat.center IS '센터';
COMMENT ON COLUMN service_heartbeat.last_seen IS '최종생존시각';
COMMENT ON COLUMN service_heartbeat.pid IS '프로세스ID';

View File

@@ -0,0 +1,30 @@
-- B1 순번기 무손실(durable-before-publish). 순번=DB SEQUENCE(원자적·durable), 발행 전 outbox에
-- 커밋 → 크래시/리더승계 시 미발행분 재발행(at-least-once). 소비측은 seq 멱등이라 중복 무해.
-- 전역 순번 시퀀스. 기존 원장 최대값 다음부터 이어서 발번(재기동·기존DB 호환).
CREATE SEQUENCE IF NOT EXISTS global_seq_seq AS BIGINT START 1;
SELECT setval(
'global_seq_seq',
GREATEST(
(SELECT COALESCE(MAX(global_seq), 0) FROM journal_log),
(SELECT COALESCE(MAX(global_seq), 0) FROM transfer),
1
),
true -- is_called=true → 다음 nextval은 max+1
);
-- 저널 아웃박스. payload = rtgs.journal로 발행되는 JournalEntry JSON 원문(재발행 시 그대로 송신).
CREATE TABLE IF NOT EXISTS journal_outbox (
global_seq BIGINT PRIMARY KEY,
bmi VARCHAR(22),
payload TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT
);
-- 미발행분 재발행 스캔용(부분 인덱스).
CREATE INDEX IF NOT EXISTS idx_outbox_unpublished ON journal_outbox (global_seq) WHERE NOT published;
COMMENT ON TABLE journal_outbox IS '순번기 저널 아웃박스(무손실 발행)';
COMMENT ON COLUMN journal_outbox.global_seq IS '전역순번';
COMMENT ON COLUMN journal_outbox.payload IS '발행 저널원문(JournalEntry JSON)';
COMMENT ON COLUMN journal_outbox.published IS '발행완료여부';

View File

@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RTGS 부하테스트 콘솔 — LouisVuitton</title>
<style>
:root { --bg:#0f1420; --card:#1a2232; --line:#2b3648; --fg:#e6ecf5; --muted:#8a97ab;
--accent:#c9a24b; --ok:#3fb950; --warn:#d29922; --bad:#f85149; --bar:#2f81f7; }
* { box-sizing:border-box; }
body { margin:0; font-family:"Segoe UI","Malgun Gothic",sans-serif; background:var(--bg); color:var(--fg); }
header { padding:18px 28px; border-bottom:1px solid var(--line); display:flex; align-items:baseline; gap:12px; }
header h1 { font-size:18px; margin:0; letter-spacing:.5px; }
header .tag { color:var(--accent); font-weight:700; }
header .sub { color:var(--muted); font-size:12px; }
main { max-width:960px; margin:0 auto; padding:24px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:18px 20px; margin-bottom:18px; }
.card h2 { font-size:14px; margin:0 0 14px; color:var(--muted); font-weight:600; text-transform:uppercase; letter-spacing:1px; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:14px; }
label { display:block; font-size:12px; color:var(--muted); margin-bottom:5px; }
input { width:100%; padding:8px 10px; background:#0d1420; border:1px solid var(--line); border-radius:6px;
color:var(--fg); font-size:14px; }
input:focus { outline:none; border-color:var(--bar); }
.actions { margin-top:16px; display:flex; gap:10px; align-items:center; }
button { padding:9px 20px; border:none; border-radius:6px; font-size:14px; font-weight:600; cursor:pointer; }
#startBtn { background:var(--bar); color:#fff; }
#stopBtn { background:transparent; color:var(--bad); border:1px solid var(--bad); }
button:disabled { opacity:.4; cursor:not-allowed; }
.msg { font-size:13px; color:var(--warn); }
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; }
.stat { background:#0d1420; border:1px solid var(--line); border-radius:8px; padding:12px 14px; }
.stat .k { font-size:11px; color:var(--muted); }
.stat .v { font-size:22px; font-weight:700; margin-top:4px; }
.v.ok { color:var(--ok); } .v.bad { color:var(--bad); } .v.warn { color:var(--warn); }
.progress { height:8px; background:#0d1420; border-radius:5px; overflow:hidden; margin:6px 0 16px; }
.progress > div { height:100%; width:0; background:var(--bar); transition:width .4s; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th,td { text-align:left; padding:7px 8px; border-bottom:1px solid var(--line); }
th { color:var(--muted); font-weight:600; }
td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; }
.barrow { display:flex; align-items:center; gap:10px; margin:5px 0; font-size:12px; }
.barrow .lbl { width:44px; color:var(--muted); }
.barrow .track { flex:1; height:14px; background:#0d1420; border-radius:4px; overflow:hidden; }
.barrow .track > div { height:100%; background:var(--bar); }
.barrow .val { width:78px; text-align:right; font-variant-numeric:tabular-nums; }
.pill { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:700; }
.pill.run { background:rgba(63,185,80,.15); color:var(--ok); }
.pill.idle { background:rgba(138,151,171,.15); color:var(--muted); }
.conserve { font-size:13px; line-height:1.9; }
.conserve .good { color:var(--ok); font-weight:700; }
.conserve .broke { color:var(--bad); font-weight:700; }
.hidden { display:none; }
.foot { color:var(--muted); font-size:11px; margin-top:8px; }
</style>
</head>
<body>
<header>
<h1>RTGS 부하테스트 콘솔 <span class="tag">LouisVuitton</span></h1>
<span class="sub">pacs.008.001.08 실시간 생성 → Chanel 접수 · 동시 요청 수 기반</span>
</header>
<main>
<!-- 파라미터 -->
<section class="card">
<h2>파라미터</h2>
<div class="grid">
<div><label>동시 요청 수 (VU)</label><input id="concurrency" type="number" min="1" max="2000" value="10"></div>
<div><label>지속시간 (초)</label><input id="durationSec" type="number" min="1" max="3600" value="15"></div>
<div><label>대상 Chanel URL</label><input id="chanelUrl" type="text" value="http://localhost:8091"></div>
<div><label>금액 최소 (원)</label><input id="amountMin" type="number" min="1" value="1"></div>
<div><label>금액 최대 (원)</label><input id="amountMax" type="number" min="1" value="200"></div>
</div>
<div class="actions">
<button id="startBtn">▶ 시작</button>
<button id="stopBtn" disabled>■ 중지</button>
<span id="statePill" class="pill idle">대기</span>
<span id="msg" class="msg"></span>
</div>
</section>
<!-- 실시간 -->
<section class="card">
<h2>실시간 진행</h2>
<div class="progress"><div id="progBar"></div></div>
<div class="stats">
<div class="stat"><div class="k">경과 / 지속</div><div class="v" id="s_elapsed">0s / 0s</div></div>
<div class="stat"><div class="k">전송</div><div class="v" id="s_sent">0</div></div>
<div class="stat"><div class="k">접수 (RCVD)</div><div class="v ok" id="s_accepted">0</div></div>
<div class="stat"><div class="k">반려</div><div class="v warn" id="s_rejected">0</div></div>
<div class="stat"><div class="k">오류</div><div class="v bad" id="s_errors">0</div></div>
<div class="stat"><div class="k">현재 TPS</div><div class="v" id="s_tps">0</div></div>
<div class="stat"><div class="k">평균 지연</div><div class="v" id="s_lat">0 ms</div></div>
</div>
</section>
<!-- 최종 요약 -->
<section class="card hidden" id="resultCard">
<h2>최종 요약</h2>
<div class="stats" style="margin-bottom:18px">
<div class="stat"><div class="k">총 전송</div><div class="v" id="r_sent">0</div></div>
<div class="stat"><div class="k">성공률 (RCVD)</div><div class="v ok" id="r_rate">0%</div></div>
<div class="stat"><div class="k">처리량</div><div class="v" id="r_tps">0 TPS</div></div>
<div class="stat"><div class="k">실제 처리시간</div><div class="v" id="r_dur">0s</div></div>
</div>
<table style="margin-bottom:6px">
<tr><th>구분</th><th class="num">건수</th><th class="num">비율</th></tr>
<tr><td>접수 (RCVD)</td><td class="num" id="rb_acc">0</td><td class="num" id="rb_accp">0%</td></tr>
<tr><td>반려 (RJCT 등)</td><td class="num" id="rb_rej">0</td><td class="num" id="rb_rejp">0%</td></tr>
<tr><td>오류 (HTTP/예외)</td><td class="num" id="rb_err">0</td><td class="num" id="rb_errp">0%</td></tr>
</table>
<h2 style="margin-top:22px">응답 지연 백분위 (ms)</h2>
<div id="latBars"></div>
</section>
<!-- 원장 정합성 -->
<section class="card hidden" id="conserveCard">
<h2>원장 정합성 (부하 전 / 후)</h2>
<div class="conserve" id="conserveBody"></div>
<div class="foot">총잔액은 이체가 계좌 간 이동일 뿐이므로 부하 전후 동일해야 한다(0-합 보존). transfer 건수는 접수분만큼 증가.</div>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
const fmt = n => (n==null?0:n).toLocaleString('ko-KR');
let timer = null, before = null;
function setRunning(run) {
$('startBtn').disabled = run;
$('stopBtn').disabled = !run;
$('statePill').textContent = run ? '실행 중' : '대기';
$('statePill').className = 'pill ' + (run ? 'run' : 'idle');
}
async function jget(u){ const r = await fetch(u); return r.json(); }
function renderStatus(s) {
const dur = (s.durationSec||0), el = (s.elapsedMs||0)/1000;
$('progBar').style.width = Math.min(100, dur? el/dur*100 : 0) + '%';
$('s_elapsed').textContent = el.toFixed(0) + 's / ' + dur + 's';
$('s_sent').textContent = fmt(s.sent);
$('s_accepted').textContent = fmt(s.accepted);
$('s_rejected').textContent = fmt(s.rejected);
$('s_errors').textContent = fmt(s.errors);
$('s_tps').textContent = s.tps;
$('s_lat').textContent = fmt(s.avgLatencyMs) + ' ms';
}
function renderResult(r) {
$('resultCard').classList.remove('hidden');
$('r_sent').textContent = fmt(r.sent);
$('r_rate').textContent = r.successRate + '%';
$('r_tps').textContent = r.throughputTps + ' TPS';
$('r_dur').textContent = ((r.durationMs||0)/1000).toFixed(1) + 's';
const sent = r.sent||0, pc = n => sent? (n*100/sent).toFixed(1)+'%' : '0%';
$('rb_acc').textContent = fmt(r.accepted); $('rb_accp').textContent = pc(r.accepted);
$('rb_rej').textContent = fmt(r.rejected); $('rb_rejp').textContent = pc(r.rejected);
$('rb_err').textContent = fmt(r.errors); $('rb_errp').textContent = pc(r.errors);
const L = r.latencyMs || {}, keys = [['min','min'],['avg','avg'],['p50','p50'],['p90','p90'],['p95','p95'],['p99','p99'],['max','max']];
const mx = Math.max(1, L.max||1);
$('latBars').innerHTML = keys.map(([k,lbl]) =>
`<div class="barrow"><span class="lbl">${lbl}</span>`+
`<span class="track"><div style="width:${Math.min(100,(L[k]||0)/mx*100)}%"></div></span>`+
`<span class="val">${fmt(L[k])} ms</span></div>`).join('');
}
function renderConserve(after) {
if (!before || !after) return;
$('conserveCard').classList.remove('hidden');
const same = before.totalBalance === after.totalBalance;
const dTx = (after.transfers||0) - (before.transfers||0);
$('conserveBody').innerHTML =
`총잔액 &nbsp; 전 <b>${fmt(before.totalBalance)}</b> &nbsp;→&nbsp; 후 <b>${fmt(after.totalBalance)}</b> &nbsp; `+
(same ? `<span class="good">✔ 보존됨 (0-합)</span>` : `<span class="broke">✘ 불일치! 차액 ${fmt(after.totalBalance-before.totalBalance)}</span>`) +
`<br>transfer 건수 &nbsp; 전 <b>${fmt(before.transfers)}</b> &nbsp;→&nbsp; 후 <b>${fmt(after.transfers)}</b> &nbsp; (증가 <b>${fmt(dTx)}</b>)` +
`<br>계좌 수 <b>${fmt(after.accounts)}</b> · 최대 globalSeq <b>${fmt(after.maxSeq)}</b>`;
}
async function poll() {
const s = await jget('/console/loadtest/status');
renderStatus(s);
if (!s.running) {
clearInterval(timer); timer = null;
setRunning(false);
const [r, after] = await Promise.all([jget('/console/loadtest/result'), jget('/console/ledger/summary')]);
renderResult(r); renderConserve(after);
}
}
$('startBtn').onclick = async () => {
$('msg').textContent = '';
$('resultCard').classList.add('hidden'); $('conserveCard').classList.add('hidden');
const body = {
concurrency: +$('concurrency').value, durationSec: +$('durationSec').value,
chanelUrl: $('chanelUrl').value.trim(),
amountMin: +$('amountMin').value, amountMax: +$('amountMax').value,
};
before = await jget('/console/ledger/summary');
const res = await fetch('/console/loadtest/start', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (!res.ok) { const e = await res.json(); $('msg').textContent = e.error || ('시작 실패 ('+res.status+')'); return; }
setRunning(true);
if (timer) clearInterval(timer);
timer = setInterval(poll, 1000); poll();
};
$('stopBtn').onclick = async () => {
await fetch('/console/loadtest/stop', {method:'POST'});
$('msg').textContent = '중지 요청됨 — 잔여 요청 마무리 중...';
};
// 페이지 로드 시 진행 중인 테스트가 있으면 이어서 표시.
(async () => {
const s = await jget('/console/loadtest/status');
if (s.running) { before = await jget('/console/ledger/summary'); setRunning(true); timer = setInterval(poll, 1000); poll(); }
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
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")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-web") // 메트릭/헬스 노출(B4)
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,21 @@
package kr.or.bok.rtgs.prada
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
@SpringBootApplication
@EntityScan("kr.or.bok.rtgs.common.ledger")
@EnableJpaRepositories("kr.or.bok.rtgs.prada.jpa")
class PradaApplication {
@Bean
fun objectMapper(): ObjectMapper = jacksonObjectMapper()
}
fun main(args: Array<String>) {
runApplication<PradaApplication>(*args)
}

View File

@@ -0,0 +1,105 @@
package kr.or.bok.rtgs.prada
import com.fasterxml.jackson.databind.ObjectMapper
import io.micrometer.core.instrument.MeterRegistry
import kr.or.bok.rtgs.common.QuorumContext
import kr.or.bok.rtgs.common.ResultMessage
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.ledger.SettlementViewEntity
import kr.or.bok.rtgs.prada.jpa.SettlementViewRepository
import kr.or.bok.rtgs.prada.jpa.TransferRepository
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* PRADA(결과동기화) — 결제결과(rtgs.result)를 받아 완결 규율(과반 확정)을 적용하고,
* 권위 원장(PostgreSQL)의 거래 상태를 최종(ACCC)으로 확정 + 조회 사본 기록.
*
* 완결 규율(B2): `rtgs.result`(= applied-ack)을 **전 센터분** 소비하여 bmi별 서로 다른
* `processedCenter` 수를 집계(QuorumAggregator)한다. 과반(2/3) 도달 전에는 ACSP(예약),
* 도달 시 ACCC(반려면 RJCT)로 승격. 1센터에서는 과반=1이라 첫 결과에 즉시 완결(기존 동작 보존).
*
* 완결 확정 후 **결과통보(rtgs.notify)** 를 발행한다. 실제 신청/수취기관 앞 pacs.002 송부는
* 접수센터(Chanel)가 담당(개요 프로세스 (4)). 발행은 **트랜잭션 커밋 후**에 해 스테일 통보를 막는다.
* 중복 통보 방지: **접수센터(origin) Prada만** notify 발행(각 센터 Prada가 독립 완결하므로).
*/
@Service
class PradaService(
private val transfers: TransferRepository,
private val views: SettlementViewRepository,
private val mapper: ObjectMapper,
private val kafka: KafkaTemplate<String, String>,
private val metrics: MeterRegistry,
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
@Value("\${rtgs.center-count:1}") private val centerCount: Int,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val aggregator by lazy { QuorumAggregator(QuorumContext(centerId, centerCount)) }
// 센터별 그룹(prada-DC1/DC2/DC3)이라 각 센터 Prada가 전 센터 result를 독립 소비.
@KafkaListener(topics = [Topics.RESULT], groupId = "prada-\${rtgs.center-id:DC1}", concurrency = "1")
fun onResult(payload: String) {
val r = mapper.readValue(payload, ResultMessage::class.java)
val notice = finalize(r) // @Transactional (반환 = 커밋 완료, 과반 최초 도달 시에만 non-null)
// 완결(ACCC/RJCT)만, 그리고 접수센터(origin) Prada만 결과통보(중복 방지).
if (notice != null && notice.originCenter == centerId) {
metrics.counter("rtgs.finality", "status", notice.finalStatus.name).increment() // B4
kafka.send(Topics.NOTIFY, notice.bmi, mapper.writeValueAsString(notice))
}
}
/**
* 완결 규율 적용 + 원장/사본 확정. 과반 **최초 도달** 시에만 완결 승격 + SettlementNotice 반환.
* 과반 미달이면 ACSP(예약)로 기록하고 null 반환. 이미 완결된 bmi는 멱등 스킵(null).
*/
@Transactional
fun finalize(r: ResultMessage): SettlementNotice? {
val e = r.journalEntry
val bmi = e.core.bmi
val now = System.currentTimeMillis()
// 이미 완결된 건에 늦게 도착한 ack: 상태를 되돌리지 않도록 멱등 스킵.
if (aggregator.isFinalized(bmi)) return null
// rtgs.result = applied-ack. 보고 센터(processedCenter) 집계 → 과반 최초 도달 여부.
val reachedQuorum = aggregator.record(bmi, r.processedCenter)
val finalStatus = when {
!reachedQuorum -> TxSts.ACSP // 과반 미달: 예약 상태 유지(아직 완결 아님)
r.status == TxSts.RJCT -> TxSts.RJCT // 과반 도달 + 반려
else -> TxSts.ACCC // 과반 도달 + 정상: 완결
}
transfers.findById(bmi).ifPresent {
it.status = finalStatus
it.updatedAt = now
if (r.reason != null) it.reason = r.reason
transfers.save(it)
}
views.save(
SettlementViewEntity(
bmi = e.core.bmi, globalSeq = e.globalSeq, finalStatus = finalStatus.name,
senderCode = e.core.senderCode, receiverCode = e.core.receiverCode, amount = e.core.amount,
originCenter = e.originCenter, processedCenter = r.processedCenter,
debtorBalanceAfter = r.debtorBalanceAfter, creditorBalanceAfter = r.creditorBalanceAfter,
finalizedAt = now,
)
)
log.info("{} seq=#{} bmi={} (origin={})", finalStatus, e.globalSeq, e.core.bmi, e.originCenter)
return if (finalStatus == TxSts.ACCC || finalStatus == TxSts.RJCT) {
SettlementNotice(
bmi = e.core.bmi, originCenter = e.originCenter,
senderCode = e.core.senderCode, receiverCode = e.core.receiverCode,
amount = e.core.amount, globalSeq = e.globalSeq,
finalStatus = finalStatus, reason = r.reason, finalizedAt = now,
)
} else null
}
}

View File

@@ -0,0 +1,45 @@
package kr.or.bok.rtgs.prada
import kr.or.bok.rtgs.common.QuorumContext
import java.util.concurrent.ConcurrentHashMap
/**
* 정족수 집계기(B2) — bmi별로 "적용 완료(applied)"를 보고한 **서로 다른 센터**를 모아
* 과반(quorum) 도달 여부를 판정한다.
*
* 신호원: `rtgs.result` = applied-ack. Hermes가 정산 트랜잭션 **커밋 후에만** 발행하므로
* (publish-after-commit) "그 센터가 해당 seq를 durable 적용했다"는 사실과 정확히 동일하다.
* `ResultMessage.processedCenter`가 보고 센터.
*
* 저장(§9.4 확정): **인메모리**. 재기동 시 맵은 비지만 — 이미 완결(ACCC/RJCT)된 건은 원장
* (`transfer.status`)에 영속되어 안전하고, 미완결(ACSP)만 이후 도착 ack로 재집계된다.
*
* 순수 로직(스프링/JPA 비의존)이라 단위테스트로 규율을 고정한다. Prada 리스너는 concurrency=1
* 단일 스레드지만, 방어적으로 동시성 안전 자료구조를 쓴다.
*/
class QuorumAggregator(private val quorum: QuorumContext) {
private val votes = ConcurrentHashMap<String, MutableSet<String>>()
private val finalized = ConcurrentHashMap.newKeySet<String>()
/** 이미 완결 승격된 bmi인가(멱등 가드 — 완결 후 늦게 도착한 ack가 상태를 되돌리지 못하게). */
fun isFinalized(bmi: String): Boolean = bmi in finalized
/**
* center의 적용 보고를 기록하고 과반 **최초 도달** 여부를 반환.
* @return true = 이 호출로 과반에 처음 도달(완결로 승격). 이미 완결이거나 아직 미달이면 false.
*/
fun record(bmi: String, center: String): Boolean {
if (bmi in finalized) return false
val set = votes.computeIfAbsent(bmi) { ConcurrentHashMap.newKeySet() }
set.add(center)
if (quorum.hasQuorum(set.size)) {
val firstReach = finalized.add(bmi) // add 성공 = 최초 1회만
if (firstReach) votes.remove(bmi) // 완결 후 표 정리(메모리 회수)
return firstReach
}
return false
}
/** 현재까지 집계된 서로 다른 센터 수(지표/디버그). */
fun count(bmi: String): Int = votes[bmi]?.size ?: 0
}

View File

@@ -0,0 +1,10 @@
package kr.or.bok.rtgs.prada.jpa
import kr.or.bok.rtgs.common.ledger.SettlementViewEntity
import kr.or.bok.rtgs.common.ledger.TransferRecord
import org.springframework.data.jpa.repository.JpaRepository
interface TransferRepository : JpaRepository<TransferRecord, String>
/** 조회 전용 사본(로컬 PostgreSQL; 클라우드는 문서형 DB). */
interface SettlementViewRepository : JpaRepository<SettlementViewEntity, String>

View File

@@ -0,0 +1,35 @@
spring:
application:
name: prada
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: prada
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: 8094
rtgs:
center-id: ${CENTER_ID:DC1}
center-count: ${CENTER_COUNT:1}
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics

View File

@@ -0,0 +1,62 @@
package kr.or.bok.rtgs.prada
import kr.or.bok.rtgs.common.QuorumContext
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/** B2 정족수 집계 규율 고정 테스트. */
class QuorumAggregatorTest {
@Test
fun `1센터는 자기 보고 1건으로 즉시 과반`() {
val agg = QuorumAggregator(QuorumContext("DC1", 1)) // quorum=1
assertTrue(agg.record("bmiA", "DC1"), "1센터: 첫 보고에 과반 도달")
assertTrue(agg.isFinalized("bmiA"))
}
@Test
fun `3센터는 2개 서로 다른 센터에서 과반 도달`() {
val agg = QuorumAggregator(QuorumContext("DC1", 3)) // quorum=2
assertFalse(agg.record("bmiA", "DC1"), "1개 센터: 아직 미달")
assertEquals(1, agg.count("bmiA"))
assertTrue(agg.record("bmiA", "DC2"), "2개 센터: 과반 최초 도달")
assertTrue(agg.isFinalized("bmiA"))
}
@Test
fun `같은 센터 중복 보고는 집계되지 않는다`() {
val agg = QuorumAggregator(QuorumContext("DC1", 3)) // quorum=2
assertFalse(agg.record("bmiA", "DC1"))
assertFalse(agg.record("bmiA", "DC1"), "동일 센터 재보고: 중복 제거 → 여전히 미달")
assertEquals(1, agg.count("bmiA"))
}
@Test
fun `과반 최초 도달은 단 한 번만 true`() {
val agg = QuorumAggregator(QuorumContext("DC1", 3)) // quorum=2
agg.record("bmiA", "DC1")
assertTrue(agg.record("bmiA", "DC2"), "최초 도달")
assertFalse(agg.record("bmiA", "DC3"), "이미 완결: 세 번째 보고는 false(멱등)")
}
@Test
fun `완결 후 늦은 ack는 상태를 되돌리지 않는다`() {
val agg = QuorumAggregator(QuorumContext("DC1", 3))
agg.record("bmiA", "DC1")
agg.record("bmiA", "DC2") // 완결
assertTrue(agg.isFinalized("bmiA"))
assertFalse(agg.record("bmiA", "DC3"))
assertTrue(agg.isFinalized("bmiA"), "여전히 완결 유지")
}
@Test
fun `서로 다른 bmi는 독립 집계`() {
val agg = QuorumAggregator(QuorumContext("DC1", 3))
assertFalse(agg.record("bmiA", "DC1"))
assertFalse(agg.record("bmiB", "DC2"))
assertFalse(agg.isFinalized("bmiA"))
assertFalse(agg.isFinalized("bmiB"))
}
}

View File

@@ -0,0 +1,21 @@
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")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-web") // 메트릭/헬스 노출(B4)
implementation("org.springframework.boot:spring-boot-starter-jdbc")
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")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

View File

@@ -0,0 +1,20 @@
package kr.or.bok.rtgs.sequencer
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling // B1 outbox 재발행 스케줄러
class SequencerApplication {
// 비웹 서비스: spring-web(Jackson2ObjectMapperBuilder)이 없어 ObjectMapper 자동설정이 안 되므로 명시 제공.
@Bean
fun objectMapper(): ObjectMapper = jacksonObjectMapper()
}
fun main(args: Array<String>) {
runApplication<SequencerApplication>(*args)
}

View File

@@ -0,0 +1,90 @@
package kr.or.bok.rtgs.sequencer
import com.fasterxml.jackson.databind.ObjectMapper
import kr.or.bok.rtgs.common.InboundRequest
import kr.or.bok.rtgs.common.JournalEntry
import kr.or.bok.rtgs.common.Topics
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
/**
* 전역 순번기(Sequencer). 입구(rtgs.inbound)에서 접수 요청을 받아 전역순번을 부여하고,
* 결정론적 값(수신시각)을 확정해 저널(rtgs.journal)로 발행한다.
*
* **B1 삼중화 — durable-before-publish (무손실):**
* 1. 순번 = Postgres SEQUENCE `global_seq_seq`.nextval (원자적·durable). 인메모리 카운터 폐기.
* 2. JournalEntry JSON을 `journal_outbox`에 커밋(published=false) → 그 후 rtgs.journal 발행 → published=true.
* 3. 발행 전 크래시/리더승계로 죽어도, 재발행 스케줄러가 미발행분을 재송신(at-least-once).
* 소비측(Dior/Hermes)은 seq 기준 멱등이라 중복은 무해.
*
* **리더선출**: 순번기 3인스턴스를 같은 컨슈머그룹 `sequencer`로 rtgs.inbound(단일 파티션) 구독 →
* Kafka가 파티션을 1개 인스턴스에만 배정 = 자동 단일 리더. 리더 死 시 리밸런스로 자동 승계.
* 승계 리더는 재발행 스케줄러로 outbox 미발행분을 이어서 내보낸다.
*
* **펜싱**: nextval이 원자적이라 순간적 이중리더가 있어도 서로 다른 seq만 발급 → 저널 오염 없음.
* (nextval 소비 후 outbox insert 실패 시 그 seq는 phantom 결번이 되며, 소비측 B3 타임아웃이 정리.)
*/
@Service
class SequencerService(
private val kafka: KafkaTemplate<String, String>,
private val mapper: ObjectMapper,
private val jdbc: JdbcTemplate,
@Value("\${rtgs.outbox-republish-grace-ms:3000}") private val republishGraceMs: Long,
) {
private val log = LoggerFactory.getLogger(javaClass)
@KafkaListener(topics = [Topics.INBOUND], groupId = "sequencer", concurrency = "1")
fun onInbound(payload: String) {
val req = mapper.readValue(payload, InboundRequest::class.java)
// 1) durable 순번 발번(SEQUENCE)
val globalSeq = jdbc.queryForObject("SELECT nextval('global_seq_seq')", Long::class.java)!!
val entry = JournalEntry(
globalSeq = globalSeq,
seqEpochMillis = System.currentTimeMillis(),
originCenter = req.originCenter,
core = req.core,
)
val json = mapper.writeValueAsString(entry)
// 2) 발행 전 outbox 커밋(durable-before-publish)
jdbc.update(
"INSERT INTO journal_outbox(global_seq, bmi, payload, published, created_at) VALUES (?,?,?,FALSE,?)",
globalSeq, entry.core.bmi, json, entry.seqEpochMillis,
)
// 3) 발행 후 published=true
publish(globalSeq, entry.core.bmi, json)
log.info("SEQ #{} bmi={} {}->{} amt={} origin={}",
globalSeq, req.core.bmi, req.core.senderCode, req.core.receiverCode, req.core.amount, req.originCenter)
}
/**
* 미발행 아웃박스 재발행(무손실·리더승계). 인라인 발행과의 경합을 피하려고 grace 이후 항목만.
* 단일 파티션 순서 유지를 위해 global_seq 오름차순으로 재발행.
*/
@Scheduled(fixedDelayString = "\${rtgs.outbox-republish-ms:2000}")
fun republishUnpublished() {
val cutoff = System.currentTimeMillis() - republishGraceMs
val rows = jdbc.queryForList(
"SELECT global_seq, bmi, payload FROM journal_outbox WHERE NOT published AND created_at < ? ORDER BY global_seq",
cutoff,
)
if (rows.isEmpty()) return
for (row in rows) {
val seq = (row["global_seq"] as Number).toLong()
val bmi = (row["bmi"] as String?) ?: ""
val json = row["payload"] as String
publish(seq, bmi, json)
log.warn("REPUBLISH seq=#{} bmi={} (미발행분 재송신)", seq, bmi)
}
}
/** rtgs.journal 발행 후 outbox를 published=true로 마킹. */
private fun publish(globalSeq: Long, bmi: String, json: String) {
kafka.send(Topics.JOURNAL, bmi, json)
jdbc.update("UPDATE journal_outbox SET published = TRUE WHERE global_seq = ?", globalSeq)
}
}

View File

@@ -0,0 +1,30 @@
spring:
application:
name: sequencer
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
username: ${POSTGRES_USER:rtgs}
password: ${POSTGRES_PASSWORD:rtgs}
kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
consumer:
group-id: sequencer
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: 8090
rtgs:
center-id: ${CENTER_ID:DC1}
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics

View File

@@ -0,0 +1,12 @@
rootProject.name = "rtgs"
include(
"common",
"sequencer",
"chanel",
"dior",
"hermes",
"prada",
"louisvuitton",
"gucci",
)