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:
37
backend/build.gradle.kts
Normal file
37
backend/build.gradle.kts
Normal 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()
|
||||
}
|
||||
}
|
||||
19
backend/chanel/build.gradle.kts
Normal file
19
backend/chanel/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
|
||||
|
||||
@SpringBootApplication
|
||||
@EntityScan("kr.or.bok.rtgs.common.ledger")
|
||||
@EnableJpaRepositories("kr.or.bok.rtgs.chanel.jpa")
|
||||
class ChanelApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<ChanelApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import kr.or.bok.rtgs.chanel.jpa.AccountRepository
|
||||
import kr.or.bok.rtgs.chanel.jpa.NotificationRepository
|
||||
import kr.or.bok.rtgs.chanel.jpa.TransferRepository
|
||||
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 전문송수신 제어 — 신청/접수(pacs.008) 및 조회(inquiry/accounts) API.
|
||||
* 조회는 권위 원장(PostgreSQL)에서 읽는다(프로토타입; 부하 분리 시 조회 전용 사본으로 확장).
|
||||
*/
|
||||
@RestController
|
||||
class ChanelController(
|
||||
private val service: ChanelService,
|
||||
private val transfers: TransferRepository,
|
||||
private val accounts: AccountRepository,
|
||||
private val notifications: NotificationRepository,
|
||||
) {
|
||||
/** 자금이체 신청/접수: pacs.008 XML → pacs.002 XML. */
|
||||
@PostMapping(
|
||||
"/pay/customer",
|
||||
consumes = [MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE, MediaType.TEXT_PLAIN_VALUE],
|
||||
produces = [MediaType.APPLICATION_XML_VALUE],
|
||||
)
|
||||
fun payCustomer(@RequestBody rawXml: String): String =
|
||||
Iso20022Codec.writePacs002(service.accept(rawXml))
|
||||
|
||||
/** 거래 상태 조회. */
|
||||
@GetMapping("/inquiry/{bmi}")
|
||||
fun inquiry(@PathVariable bmi: String): Map<String, Any?> {
|
||||
val t = transfers.findById(bmi).orElse(null)
|
||||
?: return mapOf("bmi" to bmi, "status" to "IN_FLIGHT", "note" to "not yet persisted")
|
||||
return mapOf(
|
||||
"bmi" to t.bmi,
|
||||
"globalSeq" to t.globalSeq,
|
||||
"status" to t.status.name,
|
||||
"senderCode" to t.senderCode,
|
||||
"receiverCode" to t.receiverCode,
|
||||
"amount" to t.amount,
|
||||
"originCenter" to t.originCenter,
|
||||
"updatedAt" to t.updatedAt,
|
||||
"reason" to t.reason,
|
||||
)
|
||||
}
|
||||
|
||||
/** 참가기관 당좌계좌 잔액 목록(대사·모니터링용). */
|
||||
@GetMapping("/accounts")
|
||||
fun accounts(): List<Map<String, Any?>> =
|
||||
accounts.findAll().sortedBy { it.code }.map {
|
||||
mapOf("code" to it.code, "name" to it.name, "balance" to it.balance)
|
||||
}
|
||||
|
||||
/** 결과통보 송부 내역(최근 100건). 신청/수취기관 앞 pacs.002 아웃박스. */
|
||||
@GetMapping("/notifications")
|
||||
fun notificationList(): List<Map<String, Any?>> =
|
||||
notifications.findTop100ByOrderByCreatedAtDesc().map(::toMap)
|
||||
|
||||
/** 특정 거래의 결과통보(신청기관·수취기관) 내역 — 원문 pacs.002 포함. */
|
||||
@GetMapping("/notifications/{bmi}")
|
||||
fun notificationOf(@PathVariable bmi: String): List<Map<String, Any?>> =
|
||||
notifications.findByBmiOrderByRole(bmi).map { toMap(it, includeXml = true) }
|
||||
|
||||
private fun toMap(n: kr.or.bok.rtgs.common.ledger.NotificationEntity, includeXml: Boolean = false): Map<String, Any?> =
|
||||
buildMap {
|
||||
put("bmi", n.bmi); put("toOrg", n.toOrg); put("role", n.role)
|
||||
put("finalStatus", n.finalStatus); put("originCenter", n.originCenter)
|
||||
put("reason", n.reason); put("delivered", n.delivered)
|
||||
put("createdAt", n.createdAt); put("deliveredAt", n.deliveredAt)
|
||||
if (includeXml) put("pacs002", n.pacs002Xml)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.or.bok.rtgs.chanel.jpa.RawMessageRepository
|
||||
import kr.or.bok.rtgs.common.BmiGenerator
|
||||
import kr.or.bok.rtgs.common.ledger.RawMessageEntity
|
||||
import kr.or.bok.rtgs.common.InboundRequest
|
||||
import kr.or.bok.rtgs.common.Topics
|
||||
import kr.or.bok.rtgs.common.TxSts
|
||||
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
|
||||
import kr.or.bok.rtgs.common.iso20022.Pacs002
|
||||
import kr.or.bok.rtgs.common.iso20022.XmlValidator
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* CHANEL(전문송수신) — 참가기관 pacs.008 접수 → XSD검증 → 경량명령문 추출 →
|
||||
* 원전문 MongoDB 저장 → 입구(rtgs.inbound) 발행. 응답으로 pacs.002 생성.
|
||||
*/
|
||||
@Service
|
||||
class ChanelService(
|
||||
private val kafka: KafkaTemplate<String, String>,
|
||||
private val mapper: ObjectMapper,
|
||||
private val rawRepo: RawMessageRepository,
|
||||
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val validator = XmlValidator()
|
||||
private val fallbackBmi = BmiGenerator("9999") // 요청에 BMI 없을 때 접수센터가 부여
|
||||
|
||||
fun accept(rawXml: String): Pacs002 {
|
||||
// 1) XSD 유효성 검사
|
||||
val v = validator.validate(rawXml)
|
||||
if (!v.valid) {
|
||||
log.warn("XSD invalid: {}", v.error)
|
||||
return reject("", "", "", "XSD validation failed: ${v.error}")
|
||||
}
|
||||
|
||||
// 2) 파싱 + 경량명령문 추출
|
||||
val req = try {
|
||||
Iso20022Codec.parsePacs008(rawXml)
|
||||
} catch (e: Exception) {
|
||||
return reject("", "", "", "parse error: ${e.message}")
|
||||
}
|
||||
val bmi = req.bizMsgIdr?.takeIf { it.isNotBlank() } ?: fallbackBmi.next()
|
||||
val core = try {
|
||||
Iso20022Codec.toCoreMessage(req, rawXml, bmi)
|
||||
} catch (e: Exception) {
|
||||
return reject(bmi, req.to, req.from, "business rule: ${e.message}")
|
||||
}
|
||||
|
||||
// 3) 원전문 저장(PostgreSQL text; 클라우드는 문서형 DB)
|
||||
rawRepo.save(
|
||||
RawMessageEntity(
|
||||
bmi = core.bmi, msgType = core.msgType, rawXml = rawXml,
|
||||
origHash = core.origHash, receivedAt = System.currentTimeMillis(), originCenter = centerId,
|
||||
)
|
||||
)
|
||||
|
||||
// 4) 입구(rtgs.inbound) 발행 → 순번기로
|
||||
val inbound = InboundRequest(core = core, originCenter = centerId)
|
||||
kafka.send(Topics.INBOUND, core.bmi, mapper.writeValueAsString(inbound))
|
||||
log.info("RCVD bmi={} {}->{} amt={} center={}", core.bmi, core.senderCode, core.receiverCode, core.amount, centerId)
|
||||
|
||||
// 5) 접수 응답(RCVD)
|
||||
return Pacs002(
|
||||
orgnlBizMsgIdr = core.bmi,
|
||||
from = "RTGS-$centerId",
|
||||
to = core.senderCode,
|
||||
txSts = TxSts.RCVD.name,
|
||||
clrSysRef = core.bmi,
|
||||
creDtTm = Instant.now().toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun reject(bmi: String, to: String, sender: String, reason: String) = Pacs002(
|
||||
orgnlBizMsgIdr = bmi,
|
||||
from = "RTGS-$centerId",
|
||||
to = sender,
|
||||
txSts = TxSts.RJCT.name,
|
||||
clrSysRef = reason,
|
||||
creDtTm = Instant.now().toString(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 서비스별 처리 현황(대사) 조회 — 읽기 전용. 각 서비스가 남긴 PostgreSQL 테이블을 최신순으로 반환.
|
||||
* - transfer : 원장(Dior 접수 PDNG → Hermes 결제 ACSP → Prada 완결 ACCC 흐름)
|
||||
* - journal_log : Hermes 선저널(write-ahead)
|
||||
* - settlement_view: Prada 조회 사본
|
||||
* - raw_message : Chanel 원전문(pacs.008)
|
||||
*/
|
||||
@RestController
|
||||
class LedgerController(private val jdbc: JdbcTemplate) {
|
||||
|
||||
private fun q(sql: String, limit: Int) = jdbc.queryForList(sql.replace("{L}", limit.coerceIn(1, 200).toString()))
|
||||
|
||||
@GetMapping("/ledger/transfers")
|
||||
fun transfers(@RequestParam(defaultValue = "20") limit: Int) = q(
|
||||
"SELECT bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, updated_at " +
|
||||
"FROM transfer ORDER BY global_seq DESC NULLS LAST LIMIT {L}", limit)
|
||||
|
||||
@GetMapping("/ledger/journal")
|
||||
fun journal(@RequestParam(defaultValue = "20") limit: Int) = q(
|
||||
"SELECT global_seq, bmi, applied, seq_epoch_millis FROM journal_log ORDER BY global_seq DESC LIMIT {L}", limit)
|
||||
|
||||
@GetMapping("/ledger/views")
|
||||
fun views(@RequestParam(defaultValue = "20") limit: Int) = q(
|
||||
"SELECT bmi, global_seq, final_status, debtor_balance_after, creditor_balance_after, processed_center, finalized_at " +
|
||||
"FROM settlement_view ORDER BY global_seq DESC NULLS LAST LIMIT {L}", limit)
|
||||
|
||||
@GetMapping("/ledger/rawmessages")
|
||||
fun rawmessages(@RequestParam(defaultValue = "20") limit: Int) = q(
|
||||
"SELECT bmi, msg_type, origin_center, received_at FROM raw_message ORDER BY received_at DESC LIMIT {L}", limit)
|
||||
|
||||
/** 요약: 상태별 건수 + 총액(대사) */
|
||||
@GetMapping("/ledger/summary")
|
||||
fun summary(): Map<String, Any?> {
|
||||
val byStatus = jdbc.queryForList("SELECT status, count(*) AS cnt FROM transfer GROUP BY status ORDER BY status")
|
||||
val total = jdbc.queryForObject("SELECT sum(balance) FROM account", Long::class.java)
|
||||
val accts = jdbc.queryForObject("SELECT count(*) FROM account", Long::class.java)
|
||||
return mapOf("byStatus" to byStatus, "totalBalance" to total, "accounts" to accts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import kr.or.bok.rtgs.chanel.jpa.RawMessageRepository
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 메타/원문 조회 API.
|
||||
* - /meta/labels: transfer 컬럼의 한글명(DB 코멘트=데이터 사전)을 조회 필드명(camelCase)에 매핑해 반환.
|
||||
* 화면 라벨의 단일 출처가 DB 코멘트가 되도록 함.
|
||||
* - /rawmessage/{bmi}: 접수된 ISO20022 pacs.008 원문 전문(XML) 조회.
|
||||
*/
|
||||
@RestController
|
||||
class MetaController(
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val rawRepo: RawMessageRepository,
|
||||
) {
|
||||
@GetMapping("/meta/labels")
|
||||
fun labels(): Map<String, String> {
|
||||
val rows = jdbc.queryForList(
|
||||
"""
|
||||
SELECT a.attname AS col, col_description(a.attrelid, a.attnum) AS ko
|
||||
FROM pg_attribute a
|
||||
WHERE a.attrelid = 'transfer'::regclass AND a.attnum > 0 AND NOT a.attisdropped
|
||||
ORDER BY a.attnum
|
||||
""".trimIndent()
|
||||
)
|
||||
val m = LinkedHashMap<String, String>()
|
||||
for (r in rows) {
|
||||
val col = r["col"] as? String ?: continue
|
||||
val ko = r["ko"] as? String ?: continue
|
||||
m[toCamel(col)] = ko
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@GetMapping("/rawmessage/{bmi}")
|
||||
fun rawMessage(@PathVariable bmi: String): Map<String, Any?> {
|
||||
val e = rawRepo.findById(bmi).orElse(null)
|
||||
?: return mapOf("bmi" to bmi, "found" to false)
|
||||
return mapOf(
|
||||
"bmi" to e.bmi,
|
||||
"msgType" to e.msgType,
|
||||
"receivedAt" to e.receivedAt,
|
||||
"originCenter" to e.originCenter,
|
||||
"rawXml" to e.rawXml,
|
||||
"found" to true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun toCamel(s: String): String =
|
||||
s.split("_").mapIndexed { i, p -> if (i == 0) p else p.replaceFirstChar { it.uppercase() } }.joinToString("")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.or.bok.rtgs.chanel.jpa.NotificationRepository
|
||||
import kr.or.bok.rtgs.common.SettlementNotice
|
||||
import kr.or.bok.rtgs.common.Topics
|
||||
import kr.or.bok.rtgs.common.TxSts
|
||||
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
|
||||
import kr.or.bok.rtgs.common.iso20022.Pacs002
|
||||
import kr.or.bok.rtgs.common.ledger.NotificationEntity
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* 결과통보(이체결과 송부) — Gucci에서 Chanel로 이관(2026-07-10). Chanel 헌장 "접수 + 처리결과 통보".
|
||||
*
|
||||
* Prada가 완결(ACCC/RJCT) 후 발행한 rtgs.notify를 받아, **접수센터(originCenter)의 Chanel만**
|
||||
* 신청기관(송신)·수취기관 앞 최종 pacs.002를 생성해 송부한다(개요 프로세스 (4)).
|
||||
* - 신청기관(APPLICANT): 항상 결과 통보(성공/반려)
|
||||
* - 수취기관(BENEFICIARY): 완결(ACCC) 시 입금 통보
|
||||
* 로컬은 실제 외부 전송 대신 아웃박스(notification) 기록 + 로그로 송부를 모사한다.
|
||||
* (실외부 채널/콜백 전송·전달보장은 향후 Gucci 관문이 담당)
|
||||
*/
|
||||
@Service
|
||||
class NotificationService(
|
||||
private val notifications: NotificationRepository,
|
||||
private val mapper: ObjectMapper,
|
||||
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
// 센터별 그룹(chanel-DC1/DC2/DC3): 각 센터가 notify 전량 소비 후 origin만 통보.
|
||||
@KafkaListener(topics = [Topics.NOTIFY], groupId = "chanel-\${rtgs.center-id:DC1}", concurrency = "1")
|
||||
fun onNotice(payload: String) {
|
||||
val n = mapper.readValue(payload, SettlementNotice::class.java)
|
||||
if (n.originCenter != centerId) return // 접수센터만 통보 책임
|
||||
|
||||
val pacs002 = Iso20022Codec.writePacs002(
|
||||
Pacs002(
|
||||
orgnlBizMsgIdr = n.bmi,
|
||||
from = "RTGS-$centerId",
|
||||
to = n.senderCode,
|
||||
txSts = n.finalStatus.name,
|
||||
clrSysRef = n.reason ?: "seq:${n.globalSeq}",
|
||||
creDtTm = Instant.now().toString(),
|
||||
)
|
||||
)
|
||||
|
||||
// 신청기관(송신) 앞 결과 통보 — 성공/반려 모두
|
||||
send(n, n.senderCode, "APPLICANT", pacs002)
|
||||
// 수취기관 앞 입금 통보 — 완결(ACCC)만
|
||||
if (n.finalStatus == TxSts.ACCC) send(n, n.receiverCode, "BENEFICIARY", pacs002)
|
||||
|
||||
log.info("NOTIFY 송부 bmi={} status={} → 신청기관 {}{}",
|
||||
n.bmi, n.finalStatus, n.senderCode,
|
||||
if (n.finalStatus == TxSts.ACCC) " · 수취기관 ${n.receiverCode}" else "")
|
||||
}
|
||||
|
||||
private fun send(n: SettlementNotice, toOrg: String, role: String, pacs002: String) {
|
||||
val now = System.currentTimeMillis()
|
||||
// 생성 시점엔 아웃박스에 pending(delivered=false)으로 기록.
|
||||
// 실제 외부 기관 앞 송부(콜백 POST·재시도·ACK)는 Gucci 관문(G2)이 담당한다.
|
||||
notifications.save(
|
||||
NotificationEntity(
|
||||
id = "${n.bmi}:$toOrg", bmi = n.bmi, toOrg = toOrg, role = role,
|
||||
finalStatus = n.finalStatus.name, pacs002Xml = pacs002,
|
||||
originCenter = n.originCenter, reason = n.reason,
|
||||
delivered = false, attempts = 0, createdAt = now, deliveredAt = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package kr.or.bok.rtgs.chanel.jpa
|
||||
|
||||
import kr.or.bok.rtgs.common.ledger.Account
|
||||
import kr.or.bok.rtgs.common.ledger.NotificationEntity
|
||||
import kr.or.bok.rtgs.common.ledger.RawMessageEntity
|
||||
import kr.or.bok.rtgs.common.ledger.TransferRecord
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
/** 조회(inquiry)용 읽기 접근 + 원전문 저장 — 권위 원장(PostgreSQL). */
|
||||
interface AccountRepository : JpaRepository<Account, String>
|
||||
|
||||
interface TransferRepository : JpaRepository<TransferRecord, String>
|
||||
|
||||
interface RawMessageRepository : JpaRepository<RawMessageEntity, String>
|
||||
|
||||
/** 결과통보 아웃박스 — 신청/수취기관 앞 송부한 pacs.002 기록. */
|
||||
interface NotificationRepository : JpaRepository<NotificationEntity, String> {
|
||||
fun findByBmiOrderByRole(bmi: String): List<NotificationEntity>
|
||||
fun findTop100ByOrderByCreatedAtDesc(): List<NotificationEntity>
|
||||
}
|
||||
34
backend/chanel/src/main/resources/application.yml
Normal file
34
backend/chanel/src/main/resources/application.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
spring:
|
||||
application:
|
||||
name: chanel
|
||||
datasource:
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
|
||||
username: ${POSTGRES_USER:rtgs}
|
||||
password: ${POSTGRES_PASSWORD:rtgs}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
open-in-view: false
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
|
||||
consumer:
|
||||
group-id: chanel
|
||||
auto-offset-reset: earliest
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
acks: all
|
||||
|
||||
server:
|
||||
port: 8091
|
||||
|
||||
rtgs:
|
||||
center-id: ${CENTER_ID:DC1}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
29
backend/common/build.gradle.kts
Normal file
29
backend/common/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
kr.or.bok.rtgs.common.ops.HeartbeatAutoConfiguration
|
||||
1127
backend/common/src/main/resources/iso20022/xsd/pacs.002.001.10.xsd
Normal file
1127
backend/common/src/main/resources/iso20022/xsd/pacs.002.001.10.xsd
Normal file
File diff suppressed because it is too large
Load Diff
1120
backend/common/src/main/resources/iso20022/xsd/pacs.008.001.08.xsd
Normal file
1120
backend/common/src/main/resources/iso20022/xsd/pacs.008.001.08.xsd
Normal file
File diff suppressed because it is too large
Load Diff
32
backend/common/src/main/resources/logback-spring.xml
Normal file
32
backend/common/src/main/resources/logback-spring.xml
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
20
backend/dior/build.gradle.kts
Normal file
20
backend/dior/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
30
backend/dior/src/main/resources/application.yml
Normal file
30
backend/dior/src/main/resources/application.yml
Normal 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
13
backend/gradle.properties
Normal 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
|
||||
BIN
backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
8
backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
8
backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
252
backend/gradlew
vendored
Normal 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
94
backend/gradlew.bat
vendored
Normal 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
|
||||
20
backend/gucci/build.gradle.kts
Normal file
20
backend/gucci/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
56
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Guards.kt
Normal file
56
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Guards.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ?: "")
|
||||
}
|
||||
}
|
||||
76
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Jwt.kt
Normal file
76
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Jwt.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
36
backend/gucci/src/main/resources/application.yml
Normal file
36
backend/gucci/src/main/resources/application.yml
Normal 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
|
||||
@@ -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 초과)
|
||||
}
|
||||
}
|
||||
20
backend/hermes/build.gradle.kts
Normal file
20
backend/hermes/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
37
backend/hermes/src/main/resources/application.yml
Normal file
37
backend/hermes/src/main/resources/application.yml
Normal 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
|
||||
@@ -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) // 변화 없음
|
||||
}
|
||||
}
|
||||
22
backend/louisvuitton/build.gradle.kts
Normal file
22
backend/louisvuitton/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@@ -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)}")
|
||||
}
|
||||
@@ -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/**")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
31
backend/louisvuitton/src/main/resources/application.yml
Normal file
31
backend/louisvuitton/src/main/resources/application.yml
Normal 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
|
||||
@@ -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로그인비밀키';
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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 '발행완료여부';
|
||||
218
backend/louisvuitton/src/main/resources/static/loadtest.html
Normal file
218
backend/louisvuitton/src/main/resources/static/loadtest.html
Normal 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 =
|
||||
`총잔액 전 <b>${fmt(before.totalBalance)}</b> → 후 <b>${fmt(after.totalBalance)}</b> `+
|
||||
(same ? `<span class="good">✔ 보존됨 (0-합)</span>` : `<span class="broke">✘ 불일치! 차액 ${fmt(after.totalBalance-before.totalBalance)}</span>`) +
|
||||
`<br>transfer 건수 전 <b>${fmt(before.transfers)}</b> → 후 <b>${fmt(after.transfers)}</b> (증가 <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>
|
||||
20
backend/prada/build.gradle.kts
Normal file
20
backend/prada/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
35
backend/prada/src/main/resources/application.yml
Normal file
35
backend/prada/src/main/resources/application.yml
Normal 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
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
21
backend/sequencer/build.gradle.kts
Normal file
21
backend/sequencer/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
30
backend/sequencer/src/main/resources/application.yml
Normal file
30
backend/sequencer/src/main/resources/application.yml
Normal 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
|
||||
12
backend/settings.gradle.kts
Normal file
12
backend/settings.gradle.kts
Normal file
@@ -0,0 +1,12 @@
|
||||
rootProject.name = "rtgs"
|
||||
|
||||
include(
|
||||
"common",
|
||||
"sequencer",
|
||||
"chanel",
|
||||
"dior",
|
||||
"hermes",
|
||||
"prada",
|
||||
"louisvuitton",
|
||||
"gucci",
|
||||
)
|
||||
Reference in New Issue
Block a user