diff --git a/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/ServiceControlController.kt b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/ServiceControlController.kt new file mode 100644 index 0000000..c267803 --- /dev/null +++ b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/ServiceControlController.kt @@ -0,0 +1,49 @@ +package kr.or.bok.rtgs.louisvuitton + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +/** + * 서비스 프로세스 제어 API(관리자 화면). admin 하위 경로라 기존 ADMIN JWT 인터셉터로 자동 보호. + * 제어(start·stop·restart)는 rtgs.control.enabled 및 화이트리스트(controllable)로 이중 게이트. + */ +@RestController +class ServiceControlController( + private val control: ServiceControlService, +) { + /** 서비스 7 + 인프라 통합 상태(조회는 항상 허용). */ + @GetMapping("/admin/services") + fun services(): Map = control.status() + + @PostMapping("/admin/services/{id}/start") + fun start(@PathVariable id: String) = guarded { control.start(id) } + + @PostMapping("/admin/services/{id}/stop") + fun stop(@PathVariable id: String) = guarded { control.stop(id) } + + @PostMapping("/admin/services/{id}/restart") + fun restart(@PathVariable id: String) = guarded { control.restart(id) } + + @GetMapping("/admin/services/{id}/log") + fun log(@PathVariable id: String, @RequestParam(defaultValue = "120") lines: Int): Map = + control.tailLog(id, lines) + + private fun guarded(action: () -> Map): ResponseEntity> { + if (!control.enabled) return ResponseEntity.status(HttpStatus.CONFLICT) + .body(mapOf("ok" to false, "message" to "서비스 제어 비활성화됨(rtgs.control.enabled=false)")) + return try { + ResponseEntity.ok(action()) + } catch (e: IllegalArgumentException) { + ResponseEntity.badRequest().body(mapOf("ok" to false, "message" to (e.message ?: "잘못된 요청"))) + } catch (e: IllegalStateException) { + ResponseEntity.status(HttpStatus.CONFLICT).body(mapOf("ok" to false, "message" to (e.message ?: "충돌"))) + } catch (e: Exception) { + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(mapOf("ok" to false, "message" to (e.message ?: "오류"))) + } + } +} diff --git a/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/ServiceControlService.kt b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/ServiceControlService.kt new file mode 100644 index 0000000..2b693d6 --- /dev/null +++ b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/ServiceControlService.kt @@ -0,0 +1,139 @@ +package kr.or.bok.rtgs.louisvuitton + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service +import java.io.File +import java.net.InetSocketAddress +import java.net.Socket + +/** 서비스 메타(고정). controllable=false는 관리자 화면에서 제어 불가(선행 필수: 자기자신/관문). */ +data class ServiceMeta(val id: String, val name: String, val port: Int, val controllable: Boolean) + +/** + * 서비스 프로세스 제어(기동/중지/재기동) — **로컬 프로토타입 전용**. + * 중지는 service_heartbeat에 기록된 pid를 ProcessHandle로 강제종료(선저널+Kafka 재생으로 무손실 승계). + * 기동은 LouisVuitton이 도는 동일 JDK(java.home)로 -0.1.0.jar를 ProcessBuilder로 실행. + */ +@Service +class ServiceControlService( + private val jdbc: JdbcTemplate, + @Value("\${rtgs.center-id:DC1}") private val centerId: String, + @Value("\${rtgs.center-count:1}") private val centerCount: String, + @Value("\${rtgs.control.enabled:true}") val enabled: Boolean, + @Value("\${rtgs.control.backend-dir:C:/ai-dev/workspace/rtgs/backend}") private val backendDir: String, + @Value("\${rtgs.control.log-dir:C:/ai-dev/home/temp/rtgs-logs}") private val logDir: String, +) { + private val log = LoggerFactory.getLogger(javaClass) + + private val services = listOf( + ServiceMeta("sequencer", "Sequencer", 8090, true), + ServiceMeta("chanel", "Chanel", 8091, true), + ServiceMeta("dior", "Dior", 8092, true), + ServiceMeta("hermes", "Hermes", 8093, true), + ServiceMeta("prada", "Prada", 8094, true), + ServiceMeta("gucci", "Gucci", 8095, false), + ServiceMeta("louisvuitton", "Louis Vuitton", 8099, false), + ) + private val byId = services.associateBy { it.id } + private val upThresholdMs = 15_000L + + /** 서비스 7 + 인프라(PostgreSQL/Kafka) 통합 상태. */ + fun status(): Map { + val now = System.currentTimeMillis() + val hb = jdbc.queryForList("SELECT service, last_seen, pid FROM service_heartbeat WHERE center = ?", centerId) + .associateBy { it["service"] as String } + val svc = services.map { m -> + val row = hb[m.id] + val last = (row?.get("last_seen") as? Number)?.toLong() + val ageMs = last?.let { now - it } + val up = ageMs != null && ageMs <= upThresholdMs + mapOf( + "id" to m.id, "name" to m.name, "port" to m.port, "controllable" to m.controllable, + "status" to if (up) "UP" else "DOWN", + "pid" to (row?.get("pid") as? Number)?.toLong(), "ageMs" to ageMs, + ) + } + val infra = listOf( + mapOf("id" to "postgres", "name" to "PostgreSQL", "port" to 5433, "reachable" to tcpReachable(5433)), + mapOf("id" to "kafka", "name" to "Kafka", "port" to 9092, "reachable" to tcpReachable(9092)), + ) + return mapOf("services" to svc, "infra" to infra, "controlEnabled" to enabled) + } + + fun start(id: String): Map { + val m = requireControllable(id) + if (isUp(id)) return mapOf("ok" to false, "message" to "${m.name} 이미 실행 중") + val jar = File("$backendDir/${m.id}/build/libs/${m.id}-0.1.0.jar") + require(jar.exists()) { "jar 없음: ${jar.path} (먼저 빌드 필요)" } + + File(logDir).mkdirs() + val javaExe = File(System.getProperty("java.home"), "bin/java.exe") + val pb = ProcessBuilder(javaExe.path, "-jar", jar.path) + pb.environment()["CENTER_ID"] = centerId + pb.environment()["CENTER_COUNT"] = centerCount + pb.directory(File(backendDir)) + pb.redirectErrorStream(true) + pb.redirectOutput(ProcessBuilder.Redirect.appendTo(File(logDir, "${m.id}.log"))) + val proc = pb.start() + log.warn("SERVICE START {} pid={} jar={}", m.id, proc.pid(), jar.path) + return mapOf("ok" to true, "message" to "${m.name} 기동 요청됨 (pid ${proc.pid()})", "pid" to proc.pid()) + } + + fun stop(id: String): Map { + val m = requireControllable(id) + val pid = pidOf(id) ?: return mapOf("ok" to false, "message" to "${m.name} 이미 중지(하트비트 없음)") + check(pid != ProcessHandle.current().pid()) { "자기 자신은 종료 불가" } + val killed = ProcessHandle.of(pid).map { it.destroyForcibly() }.orElse(false) + jdbc.update("DELETE FROM service_heartbeat WHERE service = ? AND center = ?", id, centerId) + log.warn("SERVICE STOP {} pid={} killed={}", m.id, pid, killed) + return mapOf("ok" to true, "message" to "${m.name} 중지 (pid $pid)", "killed" to killed) + } + + fun restart(id: String): Map { + val m = requireControllable(id) + val pid = pidOf(id) + if (pid != null && pid != ProcessHandle.current().pid()) { + ProcessHandle.of(pid).ifPresent { h -> + h.destroyForcibly() + var waited = 0 + while (h.isAlive && waited < 10_000) { Thread.sleep(200); waited += 200 } + } + jdbc.update("DELETE FROM service_heartbeat WHERE service = ? AND center = ?", id, centerId) + } + Thread.sleep(500) + val r = start(id) + return r + ("message" to "${m.name} 재기동됨") + } + + fun tailLog(id: String, lines: Int): Map { + byId[id] ?: throw IllegalArgumentException("알 수 없는 서비스: $id") + val logFile = File(logDir, "$id.log") + if (!logFile.exists()) return mapOf("id" to id, "found" to false, "lines" to emptyList()) + val all = logFile.readLines() + val n = lines.coerceIn(1, 1000) + val tail = if (all.size <= n) all else all.subList(all.size - n, all.size) + return mapOf("id" to id, "found" to true, "lines" to tail, "path" to logFile.path) + } + + private fun requireControllable(id: String): ServiceMeta { + val m = byId[id] ?: throw IllegalArgumentException("알 수 없는 서비스: $id") + require(m.controllable) { "제어 불가(선행 필수 서비스): ${m.name}" } + return m + } + + private fun isUp(id: String): Boolean { + val last = jdbc.queryForList("SELECT last_seen FROM service_heartbeat WHERE service = ? AND center = ?", id, centerId) + .firstOrNull()?.let { (it["last_seen"] as? Number)?.toLong() } ?: return false + return System.currentTimeMillis() - last <= upThresholdMs + } + + private fun pidOf(id: String): Long? = + jdbc.queryForList("SELECT pid FROM service_heartbeat WHERE service = ? AND center = ?", id, centerId) + .firstOrNull()?.let { (it["pid"] as? Number)?.toLong() } + + private fun tcpReachable(port: Int): Boolean = try { + Socket().use { it.connect(InetSocketAddress("localhost", port), 1000); true } + } catch (e: Exception) { false } +} diff --git a/backend/louisvuitton/src/main/resources/application.yml b/backend/louisvuitton/src/main/resources/application.yml index 435590c..d52f5fa 100644 --- a/backend/louisvuitton/src/main/resources/application.yml +++ b/backend/louisvuitton/src/main/resources/application.yml @@ -20,6 +20,12 @@ server: rtgs: center-id: ${CENTER_ID:DC1} + center-count: ${CENTER_COUNT:1} + # 서비스 프로세스 제어(관리자 화면 기동/중지/재기동) — 로컬 프로토타입 전용 + control: + enabled: ${RTGS_CONTROL_ENABLED:true} + backend-dir: ${RTGS_BACKEND_DIR:C:/ai-dev/workspace/rtgs/backend} + log-dir: ${RTGS_CONTROL_LOG_DIR:C:/ai-dev/home/temp/rtgs-logs} gucci: jwt-secret: ${GUCCI_JWT_SECRET:rtgs-gucci-dev-secret-key-please-change-0123456789} # Gucci와 동일 키로 토큰 검증