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:
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>
|
||||
Reference in New Issue
Block a user