feat: 닉네임 동기화 개선 및 로그 수집기 추가

- PlayerDataStore: displayName/actualName 분리 조회 함수 추가
- Essentials 닉네임 동기화 로직 개선
- LogCollector 추가 (실시간 로그 수집)
- HttpApiServer: displayName 반환 추가
This commit is contained in:
Caadiq 2025-12-23 10:07:50 +09:00
parent 19143d969c
commit 054d7b896a
11 changed files with 843 additions and 318 deletions

View file

@ -1,4 +1,3 @@
package com.beemer.essentials.nickname
import com.mojang.brigadier.arguments.StringArgumentType
@ -9,11 +8,7 @@ import net.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent
/**
* 닉네임 명령어
* /닉네임 변경 <닉네임>
* /닉네임 초기화
*/
/** 닉네임 명령어 /닉네임 변경 <닉네임> /닉네임 초기화 */
object NicknameCommand {
@SubscribeEvent
fun onRegisterCommands(event: RegisterCommandsEvent) {
@ -23,20 +18,33 @@ object NicknameCommand {
.then(
Commands.literal("변경")
.then(
Commands.argument("닉네임", StringArgumentType.greedyString())
Commands.argument(
"닉네임",
StringArgumentType
.greedyString()
)
.executes { context ->
val player = context.source.entity as? ServerPlayer
val player =
context.source
.entity as?
ServerPlayer
?: return@executes 0
val nickname = StringArgumentType.getString(context, "닉네임").trim()
val nickname =
StringArgumentType
.getString(
context,
"닉네임"
)
.trim()
executeSet(player, nickname)
}
)
)
.then(
Commands.literal("초기화")
.executes { context ->
val player = context.source.entity as? ServerPlayer
Commands.literal("초기화").executes { context ->
val player =
context.source.entity as? ServerPlayer
?: return@executes 0
executeReset(player)
@ -50,20 +58,33 @@ object NicknameCommand {
.then(
Commands.literal("set")
.then(
Commands.argument("name", StringArgumentType.greedyString())
Commands.argument(
"name",
StringArgumentType
.greedyString()
)
.executes { context ->
val player = context.source.entity as? ServerPlayer
val player =
context.source
.entity as?
ServerPlayer
?: return@executes 0
val nickname = StringArgumentType.getString(context, "name").trim()
val nickname =
StringArgumentType
.getString(
context,
"name"
)
.trim()
executeSet(player, nickname)
}
)
)
.then(
Commands.literal("reset")
.executes { context ->
val player = context.source.entity as? ServerPlayer
Commands.literal("reset").executes { context ->
val player =
context.source.entity as? ServerPlayer
?: return@executes 0
executeReset(player)
@ -76,8 +97,9 @@ object NicknameCommand {
// 유효성 검사: 길이
if (nickname.length < 2 || nickname.length > 16) {
player.sendSystemMessage(
Component.literal("닉네임은 2~16자 사이여야 합니다.")
.withStyle { it.withColor(ChatFormatting.RED) }
Component.literal("닉네임은 2~16자 사이여야 합니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
return 0
}
@ -85,21 +107,30 @@ object NicknameCommand {
// 유효성 검사: 중복
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
player.sendSystemMessage(
Component.literal("이미 사용 중인 닉네임입니다.")
.withStyle { it.withColor(ChatFormatting.RED) }
Component.literal("이미 사용 중인 닉네임입니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
return 0
}
// 닉네임 저장 및 적용
NicknameDataStore.setNickname(player.uuid, nickname)
// 닉네임 저장 및 적용 (gameProfile.name = 실제 마인크래프트 이름)
NicknameDataStore.setNickname(player.uuid, player.gameProfile.name, nickname)
NicknameManager.applyNickname(player, nickname)
player.sendSystemMessage(
Component.literal("닉네임이 ")
.withStyle { it.withColor(ChatFormatting.GOLD) }
.append(Component.literal(nickname).withStyle { it.withColor(ChatFormatting.AQUA) })
.append(Component.literal("(으)로 변경되었습니다.").withStyle { it.withColor(ChatFormatting.GOLD) })
.append(
Component.literal(nickname).withStyle {
it.withColor(ChatFormatting.AQUA)
}
)
.append(
Component.literal("(으)로 변경되었습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
)
return 1
@ -108,8 +139,9 @@ object NicknameCommand {
private fun executeReset(player: ServerPlayer): Int {
if (!NicknameDataStore.hasNickname(player.uuid)) {
player.sendSystemMessage(
Component.literal("설정된 닉네임이 없습니다.")
.withStyle { it.withColor(ChatFormatting.RED) }
Component.literal("설정된 닉네임이 없습니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
return 0
}
@ -118,8 +150,9 @@ object NicknameCommand {
NicknameManager.removeNickname(player)
player.sendSystemMessage(
Component.literal("닉네임이 초기화되었습니다.")
.withStyle { it.withColor(ChatFormatting.GOLD) }
Component.literal("닉네임이 초기화되었습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
return 1

View file

@ -11,6 +11,12 @@ import net.neoforged.fml.loading.FMLPaths
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.Logger
/** 닉네임 데이터 엔트리 - 실제 이름과 닉네임을 함께 저장 */
data class NicknameEntry(
val originalName: String, // 실제 마인크래프트 이름
val nickname: String // 설정된 닉네임
)
/** 닉네임 데이터 저장소 JSON 파일로 닉네임 저장/로드 */
object NicknameDataStore {
private const val MOD_ID = "essentials"
@ -22,8 +28,8 @@ object NicknameDataStore {
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
// UUID -> 닉네임 매핑
private val nicknames: MutableMap<String, String> = ConcurrentHashMap()
// UUID -> NicknameEntry 매핑
private val nicknames: MutableMap<String, NicknameEntry> = ConcurrentHashMap()
/** 닉네임 데이터 로드 */
fun load() {
@ -32,11 +38,26 @@ object NicknameDataStore {
if (Files.exists(FILE_PATH)) {
val json = Files.readString(FILE_PATH)
val type = object : TypeToken<Map<String, String>>() {}.type
val loaded: Map<String, String> = gson.fromJson(json, type) ?: emptyMap()
// 먼저 새 형식으로 로드 시도
try {
val type = object : TypeToken<Map<String, NicknameEntry>>() {}.type
val loaded: Map<String, NicknameEntry> = gson.fromJson(json, type) ?: emptyMap()
nicknames.clear()
nicknames.putAll(loaded)
LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}")
} catch (e: Exception) {
// 기존 형식(UUID -> String)으로 마이그레이션
val oldType = object : TypeToken<Map<String, String>>() {}.type
val oldData: Map<String, String> = gson.fromJson(json, oldType) ?: emptyMap()
nicknames.clear()
oldData.forEach { (uuid, nickname) ->
// 기존 데이터는 실제 이름을 알 수 없으므로 "Unknown"으로 설정
nicknames[uuid] = NicknameEntry("Unknown", nickname)
}
save() // 새 형식으로 저장
LOGGER.info("[Essentials] 닉네임 데이터 마이그레이션 완료: ${nicknames.size}")
}
}
} catch (e: Exception) {
LOGGER.error("[Essentials] 닉네임 데이터 로드 실패", e)
@ -54,9 +75,9 @@ object NicknameDataStore {
}
}
/** 닉네임 설정 */
fun setNickname(uuid: UUID, nickname: String) {
nicknames[uuid.toString()] = nickname
/** 닉네임 설정 (실제 이름과 함께) */
fun setNickname(uuid: UUID, originalName: String, nickname: String) {
nicknames[uuid.toString()] = NicknameEntry(originalName, nickname)
save()
}
@ -69,6 +90,18 @@ object NicknameDataStore {
/** 닉네임 조회 */
@JvmStatic
fun getNickname(uuid: UUID): String? {
return nicknames[uuid.toString()]?.nickname
}
/** 실제 이름 조회 */
@JvmStatic
fun getOriginalName(uuid: UUID): String? {
return nicknames[uuid.toString()]?.originalName
}
/** 전체 엔트리 조회 */
@JvmStatic
fun getEntry(uuid: UUID): NicknameEntry? {
return nicknames[uuid.toString()]
}
@ -77,7 +110,7 @@ object NicknameDataStore {
val target = nickname.trim()
if (target.isEmpty()) return false
return nicknames.entries.any {
it.value.equals(target, ignoreCase = true) &&
it.value.nickname.equals(target, ignoreCase = true) &&
(excludeUUID == null || it.key != excludeUUID.toString())
}
}
@ -91,7 +124,7 @@ object NicknameDataStore {
fun getUuidByNickname(nickname: String): UUID? {
val target = nickname.trim()
if (target.isEmpty()) return null
val entry = nicknames.entries.find { it.value.equals(target, ignoreCase = true) }
val entry = nicknames.entries.find { it.value.nickname.equals(target, ignoreCase = true) }
return entry?.key?.let { UUID.fromString(it) }
}
}

View file

@ -7,6 +7,8 @@ import co.caadiq.serverstatus.data.PlayerStatsCollector
import co.caadiq.serverstatus.data.PlayerTracker
import co.caadiq.serverstatus.data.ServerDataCollector
import co.caadiq.serverstatus.data.WorldDataCollector
import co.caadiq.serverstatus.log.LogCaptureAppender
import co.caadiq.serverstatus.log.LogUploadService
import co.caadiq.serverstatus.network.HttpApiServer
import net.minecraft.server.MinecraftServer
import net.neoforged.bus.api.IEventBus
@ -74,6 +76,14 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
fun onServerStarted(event: ServerStartedEvent) {
minecraftServer = event.server
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 저장됨")
// 서버 시작 시 이전 로그 파일 업로드 (비동기)
try {
val logsDir = event.server.serverDirectory.resolve("logs").toFile()
LogUploadService.uploadPreviousLogs(logsDir)
} catch (e: Exception) {
LOGGER.error("[$MOD_ID] 로그 업로드 시작 중 오류: ${e.message}")
}
}
/** 서버 종료 이벤트 */
@ -81,6 +91,7 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
fun onServerStopped(event: ServerStoppedEvent) {
minecraftServer = null
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 해제됨")
LogCaptureAppender.uninstall()
}
/** 전용 서버 설정 이벤트 */
@ -96,6 +107,9 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
httpApiServer = HttpApiServer(config.httpPort)
httpApiServer.start()
// 로그 캡쳐 Appender 설치
LogCaptureAppender.install()
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
}
}

View file

@ -1,21 +1,21 @@
package co.caadiq.serverstatus.config
import co.caadiq.serverstatus.ServerStatusMod
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.neoforged.fml.loading.FMLPaths
import java.nio.file.Files
import java.util.UUID
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.neoforged.fml.loading.FMLPaths
/**
* 모드 설정 클래스
* config/serverstatus/ 폴더에 저장
*/
/** 모드 설정 클래스 config/serverstatus/ 폴더에 저장 */
@Serializable
data class ModConfig(
val httpPort: Int = 8080
val httpPort: Int = 8080,
val serverId: String = UUID.randomUUID().toString().take(8), // 서버 고유 ID (기본값: 랜덤 8자)
val backendUrl: String = "http://minecraft-status:80" // 백엔드 API URL
) {
companion object {
private val json = Json {
@ -27,9 +27,7 @@ data class ModConfig(
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
private val configPath = configDir.resolve("config.json")
/**
* 설정 파일 로드 (없으면 기본값으로 생성)
*/
/** 설정 파일 로드 (없으면 기본값으로 생성) */
fun load(): ModConfig {
return try {
if (configPath.exists()) {
@ -46,9 +44,7 @@ data class ModConfig(
}
}
/**
* 설정 저장
*/
/** 설정 저장 */
fun save(config: ModConfig) {
try {
Files.createDirectories(configDir)

View file

@ -2,20 +2,16 @@ package co.caadiq.serverstatus.config
import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.PlayerStats
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import net.neoforged.fml.loading.FMLPaths
import java.nio.file.Files
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import net.neoforged.fml.loading.FMLPaths
/**
* 플레이어 데이터 저장소
* 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리
* config/serverstatus/ 폴더에 저장
*/
/** 플레이어 데이터 저장소 첫 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리 config/serverstatus/ 폴더에 저장 */
@Serializable
data class PlayerData(
val uuid: String,
@ -26,12 +22,9 @@ data class PlayerData(
var totalPlayTimeMs: Long = 0L,
// 마지막 로그아웃 시 저장된 통계
var savedStats: PlayerStats? = null,
@Transient
var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
@Transient var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
) {
/**
* 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산
*/
/** 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산 */
fun getCurrentSessionMs(): Long {
return if (isOnline) {
System.currentTimeMillis() - lastJoin
@ -40,18 +33,14 @@ data class PlayerData(
}
}
/**
* 실시간 플레이타임 (ms) - 누적 + 현재 세션
*/
/** 실시간 총 플레이타임 (ms) - 누적 + 현재 세션 */
fun getRealTimeTotalMs(): Long {
return totalPlayTimeMs + getCurrentSessionMs()
}
}
@Serializable
data class PlayerDataStore(
val players: MutableMap<String, PlayerData> = mutableMapOf()
) {
data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutableMapOf()) {
companion object {
private val json = Json {
prettyPrint = true
@ -63,15 +52,24 @@ data class PlayerDataStore(
private val dataPath = configDir.resolve("players.json")
// Essentials 닉네임 파일 경로
private val essentialsNicknamePath = FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json")
private val essentialsNicknamePath =
FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json")
/**
* Essentials 닉네임 조회
*/
/** Essentials 닉네임 조회 - UUID -> nickname 매핑 반환 */
private fun loadEssentialsNicknames(): Map<String, String> {
return try {
if (essentialsNicknamePath.exists()) {
json.decodeFromString<Map<String, String>>(essentialsNicknamePath.readText())
val content = essentialsNicknamePath.readText()
// 먼저 새 형식(NicknameEntry) 시도
try {
@kotlinx.serialization.Serializable
data class NicknameEntry(val originalName: String, val nickname: String)
val entries = json.decodeFromString<Map<String, NicknameEntry>>(content)
entries.mapValues { it.value.nickname }
} catch (e: Exception) {
// 기존 형식 (UUID -> String) 시도
json.decodeFromString<Map<String, String>>(content)
}
} else {
emptyMap()
}
@ -80,9 +78,28 @@ data class PlayerDataStore(
}
}
/**
* 플레이어 데이터 로드
*/
/** Essentials 실제 이름 조회 - UUID -> originalName 매핑 반환 */
private fun loadEssentialsOriginalNames(): Map<String, String> {
return try {
if (essentialsNicknamePath.exists()) {
val content = essentialsNicknamePath.readText()
try {
@kotlinx.serialization.Serializable
data class NicknameEntry(val originalName: String, val nickname: String)
val entries = json.decodeFromString<Map<String, NicknameEntry>>(content)
entries.mapValues { it.value.originalName }
} catch (e: Exception) {
emptyMap()
}
} else {
emptyMap()
}
} catch (e: Exception) {
emptyMap()
}
}
/** 플레이어 데이터 로드 */
fun load(): PlayerDataStore {
return try {
if (dataPath.exists()) {
@ -97,17 +114,19 @@ data class PlayerDataStore(
}
}
/**
* 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름)
*/
/** 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름) */
fun getDisplayName(uuid: String): String {
val essentialsNicks = loadEssentialsNicknames()
return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown"
}
/**
* 모든 플레이어 닉네임 Essentials와 동기화
*/
/** 특정 플레이어의 실제 마인크래프트 이름 가져오기 (Essentials에서 저장된 originalName 우선) */
fun getActualName(uuid: String): String {
val essentialsOriginalNames = loadEssentialsOriginalNames()
return essentialsOriginalNames[uuid] ?: players[uuid]?.name ?: "Unknown"
}
/** 모든 플레이어 닉네임 Essentials와 동기화 */
fun syncNicknamesFromEssentials() {
val essentialsNicks = loadEssentialsNicknames()
var synced = 0
@ -121,13 +140,13 @@ data class PlayerDataStore(
}
if (synced > 0) {
save()
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}")
ServerStatusMod.LOGGER.info(
"[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}"
)
}
}
/**
* 데이터 저장
*/
/** 데이터 저장 */
fun save() {
try {
Files.createDirectories(configDir)
@ -137,23 +156,18 @@ data class PlayerDataStore(
}
}
/**
* 플레이어 입장 처리
*/
/** 플레이어 입장 처리 */
fun onPlayerJoin(uuid: String, name: String) {
val now = System.currentTimeMillis()
val player = players.getOrPut(uuid) {
PlayerData(uuid = uuid, name = name, firstJoin = now)
}
val player =
players.getOrPut(uuid) { PlayerData(uuid = uuid, name = name, firstJoin = now) }
player.name = name
player.lastJoin = now
player.isOnline = true
save()
}
/**
* 플레이어 퇴장 처리 (통계 저장 포함)
*/
/** 플레이어 퇴장 처리 (통계 저장 포함) */
fun onPlayerLeave(uuid: String, stats: PlayerStats?) {
val now = System.currentTimeMillis()
players[uuid]?.let { player ->
@ -164,29 +178,23 @@ data class PlayerDataStore(
// 통계 저장
if (stats != null) {
player.savedStats = stats
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}")
ServerStatusMod.LOGGER.info(
"[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}"
)
}
save()
}
}
/**
* 플레이어 정보 조회
*/
/** 플레이어 정보 조회 */
fun getPlayer(uuid: String): PlayerData? = players[uuid]
/**
* 전체 플레이어 목록 조회
*/
/** 전체 플레이어 목록 조회 */
fun getAllPlayers(): List<PlayerData> = players.values.toList().sortedBy { it.name }
/**
* 플레이어 온라인 상태 확인
*/
/** 플레이어 온라인 상태 확인 */
fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false
/**
* 저장된 통계 조회
*/
/** 저장된 통계 조회 */
fun getSavedStats(uuid: String): PlayerStats? = players[uuid]?.savedStats
}

View file

@ -0,0 +1,46 @@
package co.caadiq.serverstatus.data
import java.util.concurrent.ConcurrentLinkedDeque
import kotlinx.serialization.Serializable
/** 서버 로그 수집기 최근 로그를 메모리에 저장하고 API로 제공 */
object LogCollector {
// 최대 저장할 로그 수
private const val MAX_LOGS = 500
// 로그 저장소 (thread-safe)
private val logs = ConcurrentLinkedDeque<LogEntry>()
/** 로그 추가 */
fun addLog(level: String, message: String) {
val entry =
LogEntry(
time = java.time.LocalTime.now().toString().substring(0, 8),
level = level,
message = message
)
logs.addLast(entry)
// 최대 개수 초과 시 오래된 로그 제거
while (logs.size > MAX_LOGS) {
logs.pollFirst()
}
}
/** 모든 로그 조회 */
fun getLogs(): List<LogEntry> = logs.toList()
/** 최근 N개 로그 조회 */
fun getRecentLogs(count: Int): List<LogEntry> {
val allLogs = logs.toList()
return if (allLogs.size <= count) allLogs else allLogs.takeLast(count)
}
/** 로그 초기화 */
fun clear() {
logs.clear()
}
}
@Serializable data class LogEntry(val time: String, val level: String, val message: String)

View file

@ -4,15 +4,10 @@ import co.caadiq.serverstatus.ServerStatusMod
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.entity.player.PlayerEvent
/**
* 플레이어 이벤트 추적기
* 입장/퇴장 이벤트를 감지하여 데이터 저장
*/
/** 플레이어 이벤트 추적기 입장/퇴장 이벤트를 감지하여 데이터 저장 */
object PlayerTracker {
/**
* 플레이어 입장 이벤트
*/
/** 플레이어 입장 이벤트 */
@SubscribeEvent
fun onPlayerJoin(event: PlayerEvent.PlayerLoggedInEvent) {
val player = event.entity
@ -25,9 +20,7 @@ object PlayerTracker {
ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name)
}
/**
* 플레이어 퇴장 이벤트
*/
/** 플레이어 퇴장 이벤트 */
@SubscribeEvent
fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) {
val player = event.entity
@ -37,10 +30,13 @@ object PlayerTracker {
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)")
// 통계 수집 후 저장
val stats = try {
val stats =
try {
ServerStatusMod.playerStatsCollector.collectStats(uuid)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}")
ServerStatusMod.LOGGER.error(
"[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}"
)
null
}

View file

@ -0,0 +1,119 @@
package co.caadiq.serverstatus.log
import co.caadiq.serverstatus.ServerStatusMod
import java.io.DataOutputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
/** 서버 시작 시 이전 로그 파일을 백엔드로 업로드하는 서비스 */
object LogUploadService {
private val executor = Executors.newSingleThreadExecutor()
/** 서버 시작 시 이전 로그 파일 업로드 (비동기) */
fun uploadPreviousLogs(logsDir: File) {
executor.submit {
try {
doUpload(logsDir)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[LogUpload] 업로드 중 예외 발생: ${e.message}")
}
}
}
private fun doUpload(logsDir: File) {
val config = ServerStatusMod.config
val serverId = config.serverId
val backendUrl = config.backendUrl
ServerStatusMod.LOGGER.info("[LogUpload] 이전 로그 파일 업로드 시작... (serverId: $serverId)")
if (!logsDir.exists() || !logsDir.isDirectory) {
ServerStatusMod.LOGGER.warn("[LogUpload] logs 폴더가 존재하지 않습니다: ${logsDir.absolutePath}")
return
}
// 모든 로그 파일 업로드 (.log, .log.gz)
val logFiles =
logsDir.listFiles()?.filter {
it.isFile && (it.name.endsWith(".log") || it.name.endsWith(".log.gz"))
}
?: emptyList()
if (logFiles.isEmpty()) {
ServerStatusMod.LOGGER.info("[LogUpload] 업로드할 로그 파일이 없습니다")
return
}
ServerStatusMod.LOGGER.info("[LogUpload] ${logFiles.size}개 파일 업로드 예정")
var successCount = 0
logFiles.forEach { file ->
try {
uploadFile(backendUrl, serverId, file)
ServerStatusMod.LOGGER.info("[LogUpload] 업로드 성공: ${file.name}")
// 업로드 성공 시 파일 삭제 (중복 업로드 방지)
if (file.delete()) {
ServerStatusMod.LOGGER.info("[LogUpload] 파일 삭제됨: ${file.name}")
}
successCount++
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[LogUpload] 업로드 실패: ${file.name} - ${e.message}")
}
}
ServerStatusMod.LOGGER.info("[LogUpload] 업로드 완료: $successCount/${logFiles.size}개 성공")
}
/** 파일 업로드 (multipart/form-data) */
private fun uploadFile(backendUrl: String, serverId: String, file: File) {
val url = URL("$backendUrl/api/admin/logs/upload")
val boundary = "----FormBoundary${System.currentTimeMillis()}"
val connection = url.openConnection() as HttpURLConnection
connection.doOutput = true
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
connection.connectTimeout = 30000
connection.readTimeout = 60000
DataOutputStream(connection.outputStream).use { out ->
// serverId 필드
out.writeBytes("--$boundary\r\n")
out.writeBytes("Content-Disposition: form-data; name=\"serverId\"\r\n\r\n")
out.writeBytes("$serverId\r\n")
// fileType 필드 (파일명으로 타입 결정)
val fileType =
when {
file.name.startsWith("debug") -> "debug"
file.name.startsWith("latest") -> "latest"
else -> "dated"
}
out.writeBytes("--$boundary\r\n")
out.writeBytes("Content-Disposition: form-data; name=\"fileType\"\r\n\r\n")
out.writeBytes("$fileType\r\n")
// 파일 필드
out.writeBytes("--$boundary\r\n")
out.writeBytes(
"Content-Disposition: form-data; name=\"file\"; filename=\"${file.name}\"\r\n"
)
val contentType = if (file.name.endsWith(".gz")) "application/gzip" else "text/plain"
out.writeBytes("Content-Type: $contentType\r\n\r\n")
out.write(file.readBytes())
out.writeBytes("\r\n")
out.writeBytes("--$boundary--\r\n")
}
val responseCode = connection.responseCode
if (responseCode !in 200..299) {
throw Exception("HTTP $responseCode: ${connection.responseMessage}")
}
connection.disconnect()
}
}

View file

@ -0,0 +1,91 @@
package co.caadiq.serverstatus.log
import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.LogCollector
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import org.apache.logging.log4j.Level
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.core.LogEvent
import org.apache.logging.log4j.core.LoggerContext
import org.apache.logging.log4j.core.appender.AbstractAppender
import org.apache.logging.log4j.core.config.Property
/** 커스텀 Log4j Appender 모든 서버 로그를 LogCollector로 전달 */
class LogCaptureAppender private constructor(name: String) :
AbstractAppender(name, null, null, true, Property.EMPTY_ARRAY) {
companion object {
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
private var instance: LogCaptureAppender? = null
/** Appender 초기화 및 등록 */
fun install() {
try {
val ctx = LogManager.getContext(false) as LoggerContext
val config = ctx.configuration
val rootLogger = config.rootLogger
// 이미 등록되어 있으면 스킵
if (instance != null) return
// Appender 생성 및 시작
instance = LogCaptureAppender("ServerStatusLogCapture")
instance!!.start()
// Root Logger에 추가
rootLogger.addAppender(instance, Level.INFO, null)
ctx.updateLoggers()
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 로그 캡처 Appender 설치됨")
} catch (e: Exception) {
ServerStatusMod.LOGGER.error(
"[${ServerStatusMod.MOD_ID}] Appender 설치 실패: ${e.message}"
)
}
}
/** Appender 제거 */
fun uninstall() {
try {
instance?.let { appender ->
val ctx = LogManager.getContext(false) as LoggerContext
val config = ctx.configuration
val rootLogger = config.rootLogger
rootLogger.removeAppender("ServerStatusLogCapture")
appender.stop()
ctx.updateLoggers()
instance = null
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 로그 캡처 Appender 제거됨")
}
} catch (e: Exception) {
// 무시
}
}
}
override fun append(event: LogEvent) {
try {
val time = LocalTime.now().format(timeFormatter)
val level = event.level.name()
val loggerName = event.loggerName?.substringAfterLast('.') ?: "Unknown"
val message = event.message?.formattedMessage ?: ""
// 빈 메시지 무시
if (message.isBlank()) return
// 자기 자신의 로그 무시 (무한 루프 방지)
if (loggerName == "ServerStatusLogCapture") return
// 로그 메시지 포맷
val formattedMessage = "[$loggerName] $message"
// LogCollector에 추가
LogCollector.addLog(level, formattedMessage)
} catch (e: Exception) {
// 로그 처리 중 오류 무시
}
}
}

View file

@ -1,6 +1,8 @@
package co.caadiq.serverstatus.network
import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.LogCollector
import co.caadiq.serverstatus.data.LogEntry
import co.caadiq.serverstatus.data.WorldInfo
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
@ -35,6 +37,10 @@ class HttpApiServer(private val port: Int) {
server?.createContext("/player", PlayerHandler())
server?.createContext("/worlds", WorldsHandler())
server?.createContext("/command", CommandHandler())
server?.createContext("/logs", LogsHandler())
server?.createContext("/logfiles", LogFilesHandler())
server?.createContext("/logfile", LogFileDownloadHandler())
server?.createContext("/banlist", BanlistHandler())
server?.start()
ServerStatusMod.LOGGER.info(
@ -66,13 +72,43 @@ class HttpApiServer(private val port: Int) {
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
// Essentials에서 닉네임 조회 (없으면 저장된 이름 사용)
val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid)
val actualName = ServerStatusMod.playerDataStore.getActualName(player.uuid)
// OP 여부 확인
val isOp: Boolean =
try {
val server =
net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer()
if (server != null) {
val playerList = server.playerList
if (player.isOnline) {
// 온라인 플레이어: 서버에서 직접 확인
val serverPlayer =
playerList.players.find { it.stringUUID == player.uuid }
if (serverPlayer != null) playerList.isOp(serverPlayer.gameProfile)
else false
} else {
// 오프라인 플레이어: GameProfile로 확인
val uuid = java.util.UUID.fromString(player.uuid)
val profile = com.mojang.authlib.GameProfile(uuid, actualName)
playerList.isOp(profile)
}
} else {
false
}
} catch (e: Exception) {
false
}
return PlayerDetail(
uuid = player.uuid,
name = displayName,
name = actualName,
displayName = displayName,
firstJoin = player.firstJoin,
lastJoin = player.lastJoin,
lastLeave = player.lastLeave,
isOnline = player.isOnline,
isOp = isOp,
currentSessionMs = player.getCurrentSessionMs(),
totalPlayTimeMs = player.totalPlayTimeMs
)
@ -253,9 +289,8 @@ class HttpApiServer(private val port: Int) {
try {
val commandSource = server.createCommandSourceStack()
server.commands.performPrefixedCommand(commandSource, cleanCommand)
ServerStatusMod.LOGGER.info("[Admin] 명령어 실행: $cleanCommand")
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[Admin] 명령어 실행 실패: ${e.message}")
// 명령어 실행 실패는 무시 (콘솔에 이미 표시됨)
}
}
@ -273,8 +308,200 @@ class HttpApiServer(private val port: Int) {
}
}
}
/** GET /logs - 서버 로그 조회 */
private inner class LogsHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "", 204)
return
}
try {
val logs = LogCollector.getLogs()
val response = json.encodeToString(LogsResponse(logs))
sendJsonResponse(exchange, response)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("로그 조회 오류: ${e.message}")
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
}
}
}
/** 로그 파일 목록 핸들러 */
private inner class LogFilesHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "", 204)
return
}
try {
val logsDir =
ServerStatusMod.minecraftServer?.serverDirectory?.resolve("logs")?.toFile()
if (logsDir == null || !logsDir.exists()) {
sendJsonResponse(exchange, """{"files": []}""")
return
}
val files =
logsDir.listFiles()
?.filter {
it.isFile &&
(it.name.endsWith(".log") ||
it.name.endsWith(".log.gz"))
}
?.sortedByDescending { it.lastModified() }
?.take(30) // 최근 30개만
?.map { file ->
LogFileInfo(
name = file.name,
size = formatFileSize(file.length()),
date =
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm")
.format(
java.util.Date(
file.lastModified()
)
)
)
}
?: emptyList()
val response = json.encodeToString(LogFilesResponse(files))
sendJsonResponse(exchange, response)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("로그 파일 목록 조회 오류: ${e.message}")
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
}
}
private fun formatFileSize(bytes: Long): String {
return when {
bytes >= 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
bytes >= 1024 -> String.format("%.1f KB", bytes / 1024.0)
else -> "$bytes B"
}
}
}
/** 로그 파일 다운로드 핸들러 */
private inner class LogFileDownloadHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "", 204)
return
}
try {
// URL에서 파일명 추출 (/logfile?name=xxx.log.gz)
val query = exchange.requestURI.query ?: ""
val params =
query.split("&").associate {
val parts = it.split("=", limit = 2)
if (parts.size == 2)
parts[0] to java.net.URLDecoder.decode(parts[1], "UTF-8")
else parts[0] to ""
}
val fileName = params["name"]
if (fileName.isNullOrBlank()) {
sendJsonResponse(exchange, """{"error": "File name required"}""", 400)
return
}
// 보안: 파일명에 경로 구분자가 포함되면 거부
if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
sendJsonResponse(exchange, """{"error": "Invalid file name"}""", 400)
return
}
val logsDir =
ServerStatusMod.minecraftServer?.serverDirectory?.resolve("logs")?.toFile()
val file = logsDir?.resolve(fileName)
if (file == null || !file.exists() || !file.isFile) {
sendJsonResponse(exchange, """{"error": "File not found"}""", 404)
return
}
// 파일 다운로드 응답
exchange.responseHeaders.add("Access-Control-Allow-Origin", "*")
exchange.responseHeaders.add("Content-Type", "application/octet-stream")
exchange.responseHeaders.add(
"Content-Disposition",
"attachment; filename=\"$fileName\""
)
exchange.sendResponseHeaders(200, file.length())
exchange.responseBody.use { output ->
file.inputStream().use { input -> input.copyTo(output) }
}
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("로그 파일 다운로드 오류: ${e.message}")
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
}
}
}
/** GET /banlist - 밴 목록 조회 (banned-players.json 읽기) */
inner class BanlistHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "")
return
}
try {
// 서버 루트 디렉토리에서 banned-players.json 파일 읽기
val server = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer()
val bannedPlayersFile =
server?.serverDirectory?.resolve("banned-players.json")?.toFile()
?: java.io.File("banned-players.json")
val banList =
if (bannedPlayersFile.exists()) {
try {
val content = bannedPlayersFile.readText(Charsets.UTF_8)
Json.decodeFromString<List<BannedPlayer>>(content)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error(
"[${ServerStatusMod.MOD_ID}] 밴 목록 파싱 오류: ${e.message}"
)
emptyList()
}
} else {
emptyList()
}
val response = BanlistResponse(banList)
sendJsonResponse(exchange, Json.encodeToString(response))
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 밴 목록 조회 오류: ${e.message}")
sendJsonResponse(exchange, """{"banList": [], "error": "${e.message}"}""", 500)
}
}
}
}
@Serializable data class WorldsResponse(val worlds: List<WorldInfo>)
@Serializable data class CommandRequest(val command: String)
@Serializable data class LogsResponse(val logs: List<LogEntry>)
@Serializable data class LogFileInfo(val name: String, val size: String, val date: String)
@Serializable data class LogFilesResponse(val files: List<LogFileInfo>)
@Serializable
data class BannedPlayer(
val uuid: String,
val name: String,
val created: String,
val source: String,
val expires: String,
val reason: String
)
@Serializable data class BanlistResponse(val banList: List<BannedPlayer>)

View file

@ -2,9 +2,7 @@ package co.caadiq.serverstatus.network
import kotlinx.serialization.Serializable
/**
* 서버 상태
*/
/** 서버 상태 */
@Serializable
data class ServerStatus(
val online: Boolean,
@ -17,72 +15,36 @@ data class ServerStatus(
val mods: List<ModInfo>
)
/**
* 플레이어 정보
*/
/** 플레이어 정보 */
@Serializable
data class PlayersInfo(
val current: Int,
val max: Int,
val online: List<OnlinePlayer>
)
data class PlayersInfo(val current: Int, val max: Int, val online: List<OnlinePlayer>)
/**
* 접속 중인 플레이어
*/
@Serializable
data class OnlinePlayer(
val name: String,
val uuid: String,
val isOp: Boolean
)
/** 접속 중인 플레이어 */
@Serializable data class OnlinePlayer(val name: String, val uuid: String, val isOp: Boolean)
/**
* 모드 정보
*/
@Serializable
data class ModInfo(
val id: String,
val version: String
)
/** 모드 정보 */
@Serializable data class ModInfo(val id: String, val version: String)
/**
* 플레이어 상세 정보 (전체 플레이어용)
*/
/** 플레이어 상세 정보 (전체 플레이어용) */
@Serializable
data class PlayerDetail(
val uuid: String,
val name: String,
val displayName: String, // Essentials 닉네임 (없으면 name과 동일)
val firstJoin: Long,
val lastJoin: Long,
val lastLeave: Long,
val isOnline: Boolean,
val isOp: Boolean, // OP 여부
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만)
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값)
)
/**
* 전체 플레이어 목록 응답
*/
@Serializable
data class AllPlayersResponse(
val players: List<PlayerDetail>
)
/** 전체 플레이어 목록 응답 */
@Serializable data class AllPlayersResponse(val players: List<PlayerDetail>)
/**
* 플레이어 입장 이벤트
*/
@Serializable
data class PlayerJoinEvent(
val uuid: String,
val name: String
)
/** 플레이어 입장 이벤트 */
@Serializable data class PlayerJoinEvent(val uuid: String, val name: String)
/**
* 플레이어 퇴장 이벤트
*/
@Serializable
data class PlayerLeaveEvent(
val uuid: String,
val name: String
)
/** 플레이어 퇴장 이벤트 */
@Serializable data class PlayerLeaveEvent(val uuid: String, val name: String)