feat: 닉네임 동기화 개선 및 로그 수집기 추가
- PlayerDataStore: displayName/actualName 분리 조회 함수 추가 - Essentials 닉네임 동기화 로직 개선 - LogCollector 추가 (실시간 로그 수집) - HttpApiServer: displayName 반환 추가
This commit is contained in:
parent
19143d969c
commit
054d7b896a
11 changed files with 843 additions and 318 deletions
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
package com.beemer.essentials.nickname
|
||||
|
||||
import com.mojang.brigadier.arguments.StringArgumentType
|
||||
|
|
@ -9,119 +8,153 @@ 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) {
|
||||
// 한글 명령어
|
||||
event.dispatcher.register(
|
||||
Commands.literal("닉네임")
|
||||
.then(
|
||||
Commands.literal("변경")
|
||||
.then(
|
||||
Commands.argument("닉네임", StringArgumentType.greedyString())
|
||||
.executes { context ->
|
||||
val player = context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
val nickname = StringArgumentType.getString(context, "닉네임").trim()
|
||||
executeSet(player, nickname)
|
||||
@SubscribeEvent
|
||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
// 한글 명령어
|
||||
event.dispatcher.register(
|
||||
Commands.literal("닉네임")
|
||||
.then(
|
||||
Commands.literal("변경")
|
||||
.then(
|
||||
Commands.argument(
|
||||
"닉네임",
|
||||
StringArgumentType
|
||||
.greedyString()
|
||||
)
|
||||
.executes { context ->
|
||||
val player =
|
||||
context.source
|
||||
.entity as?
|
||||
ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
val nickname =
|
||||
StringArgumentType
|
||||
.getString(
|
||||
context,
|
||||
"닉네임"
|
||||
)
|
||||
.trim()
|
||||
executeSet(player, nickname)
|
||||
}
|
||||
)
|
||||
)
|
||||
.then(
|
||||
Commands.literal("초기화").executes { context ->
|
||||
val player =
|
||||
context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
executeReset(player)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 영어 명령어
|
||||
event.dispatcher.register(
|
||||
Commands.literal("nickname")
|
||||
.then(
|
||||
Commands.literal("set")
|
||||
.then(
|
||||
Commands.argument(
|
||||
"name",
|
||||
StringArgumentType
|
||||
.greedyString()
|
||||
)
|
||||
.executes { context ->
|
||||
val player =
|
||||
context.source
|
||||
.entity as?
|
||||
ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
val nickname =
|
||||
StringArgumentType
|
||||
.getString(
|
||||
context,
|
||||
"name"
|
||||
)
|
||||
.trim()
|
||||
executeSet(player, nickname)
|
||||
}
|
||||
)
|
||||
)
|
||||
.then(
|
||||
Commands.literal("reset").executes { context ->
|
||||
val player =
|
||||
context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
executeReset(player)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeSet(player: ServerPlayer, nickname: String): Int {
|
||||
// 유효성 검사: 길이
|
||||
if (nickname.length < 2 || nickname.length > 16) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("닉네임은 2~16자 사이여야 합니다.").withStyle {
|
||||
it.withColor(ChatFormatting.RED)
|
||||
}
|
||||
)
|
||||
)
|
||||
.then(
|
||||
Commands.literal("초기화")
|
||||
.executes { context ->
|
||||
val player = context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
executeReset(player)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 영어 명령어
|
||||
event.dispatcher.register(
|
||||
Commands.literal("nickname")
|
||||
.then(
|
||||
Commands.literal("set")
|
||||
.then(
|
||||
Commands.argument("name", StringArgumentType.greedyString())
|
||||
.executes { context ->
|
||||
val player = context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
val nickname = StringArgumentType.getString(context, "name").trim()
|
||||
executeSet(player, nickname)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 유효성 검사: 중복
|
||||
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("이미 사용 중인 닉네임입니다.").withStyle {
|
||||
it.withColor(ChatFormatting.RED)
|
||||
}
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 닉네임 저장 및 적용 (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)
|
||||
}
|
||||
)
|
||||
)
|
||||
.then(
|
||||
Commands.literal("reset")
|
||||
.executes { context ->
|
||||
val player = context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
executeReset(player)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
private fun executeReset(player: ServerPlayer): Int {
|
||||
if (!NicknameDataStore.hasNickname(player.uuid)) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("설정된 닉네임이 없습니다.").withStyle {
|
||||
it.withColor(ChatFormatting.RED)
|
||||
}
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
NicknameDataStore.removeNickname(player.uuid)
|
||||
NicknameManager.removeNickname(player)
|
||||
|
||||
player.sendSystemMessage(
|
||||
Component.literal("닉네임이 초기화되었습니다.").withStyle {
|
||||
it.withColor(ChatFormatting.GOLD)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeSet(player: ServerPlayer, nickname: String): Int {
|
||||
// 유효성 검사: 길이
|
||||
if (nickname.length < 2 || nickname.length > 16) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("닉네임은 2~16자 사이여야 합니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||
)
|
||||
return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// 유효성 검사: 중복
|
||||
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("이미 사용 중인 닉네임입니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 닉네임 저장 및 적용
|
||||
NicknameDataStore.setNickname(player.uuid, 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) })
|
||||
)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
private fun executeReset(player: ServerPlayer): Int {
|
||||
if (!NicknameDataStore.hasNickname(player.uuid)) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("설정된 닉네임이 없습니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
NicknameDataStore.removeNickname(player.uuid)
|
||||
NicknameManager.removeNickname(player)
|
||||
|
||||
player.sendSystemMessage(
|
||||
Component.literal("닉네임이 초기화되었습니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||
)
|
||||
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
nicknames.clear()
|
||||
nicknames.putAll(loaded)
|
||||
LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}개")
|
||||
|
||||
// 먼저 새 형식으로 로드 시도
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,33 @@
|
|||
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 {
|
||||
prettyPrint = true
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
|
||||
// config/serverstatus/ 폴더에 저장
|
||||
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()) {
|
||||
|
|
@ -45,10 +43,8 @@ data class ModConfig(
|
|||
ModConfig()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 저장
|
||||
*/
|
||||
|
||||
/** 설정 저장 */
|
||||
fun save(config: ModConfig) {
|
||||
try {
|
||||
Files.createDirectories(configDir)
|
||||
|
|
|
|||
|
|
@ -2,36 +2,29 @@ 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,
|
||||
var name: String,
|
||||
var firstJoin: Long = System.currentTimeMillis(),
|
||||
var lastJoin: Long = System.currentTimeMillis(),
|
||||
var lastLeave: Long = 0L,
|
||||
var totalPlayTimeMs: Long = 0L,
|
||||
// 마지막 로그아웃 시 저장된 통계
|
||||
var savedStats: PlayerStats? = null,
|
||||
@Transient
|
||||
var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
|
||||
val uuid: String,
|
||||
var name: String,
|
||||
var firstJoin: Long = System.currentTimeMillis(),
|
||||
var lastJoin: Long = System.currentTimeMillis(),
|
||||
var lastLeave: Long = 0L,
|
||||
var totalPlayTimeMs: Long = 0L,
|
||||
// 마지막 로그아웃 시 저장된 통계
|
||||
var savedStats: PlayerStats? = null,
|
||||
@Transient var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
|
||||
) {
|
||||
/**
|
||||
* 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산
|
||||
*/
|
||||
/** 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산 */
|
||||
fun getCurrentSessionMs(): Long {
|
||||
return if (isOnline) {
|
||||
System.currentTimeMillis() - lastJoin
|
||||
|
|
@ -39,39 +32,44 @@ data class PlayerData(
|
|||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 총 플레이타임 (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
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
|
||||
// config/serverstatus/ 폴더에 저장
|
||||
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
||||
private val dataPath = configDir.resolve("players.json")
|
||||
|
||||
|
||||
// Essentials 닉네임 파일 경로
|
||||
private val essentialsNicknamePath = FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json")
|
||||
|
||||
/**
|
||||
* Essentials 닉네임 조회
|
||||
*/
|
||||
private val essentialsNicknamePath =
|
||||
FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json")
|
||||
|
||||
/** 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()
|
||||
}
|
||||
|
|
@ -79,10 +77,29 @@ data class PlayerDataStore(
|
|||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이어 데이터 로드
|
||||
*/
|
||||
|
||||
/** 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()) {
|
||||
|
|
@ -96,18 +113,20 @@ 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)
|
||||
|
|
@ -136,24 +155,19 @@ data class PlayerDataStore(
|
|||
ServerStatusMod.LOGGER.error("플레이어 데이터 저장 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이어 입장 처리
|
||||
*/
|
||||
|
||||
/** 플레이어 입장 처리 */
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -4,46 +4,42 @@ 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
|
||||
val uuid = player.stringUUID
|
||||
val name = player.name.string
|
||||
|
||||
|
||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 입장: $name ($uuid)")
|
||||
|
||||
|
||||
// 플레이어 데이터 저장
|
||||
ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이어 퇴장 이벤트
|
||||
*/
|
||||
|
||||
/** 플레이어 퇴장 이벤트 */
|
||||
@SubscribeEvent
|
||||
fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) {
|
||||
val player = event.entity
|
||||
val uuid = player.stringUUID
|
||||
val name = player.name.string
|
||||
|
||||
|
||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)")
|
||||
|
||||
|
||||
// 통계 수집 후 저장
|
||||
val stats = try {
|
||||
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
val stats =
|
||||
try {
|
||||
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error(
|
||||
"[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}"
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
// 플레이어 데이터 및 통계 저장
|
||||
ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
// 로그 처리 중 오류 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>)
|
||||
|
|
|
|||
|
|
@ -2,87 +2,49 @@ package co.caadiq.serverstatus.network
|
|||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 서버 상태
|
||||
*/
|
||||
/** 서버 상태 */
|
||||
@Serializable
|
||||
data class ServerStatus(
|
||||
val online: Boolean,
|
||||
val version: String,
|
||||
val modLoader: String,
|
||||
val difficulty: String,
|
||||
val uptimeMinutes: Long,
|
||||
val players: PlayersInfo,
|
||||
val gameRules: Map<String, Boolean>,
|
||||
val mods: List<ModInfo>
|
||||
val online: Boolean,
|
||||
val version: String,
|
||||
val modLoader: String,
|
||||
val difficulty: String,
|
||||
val uptimeMinutes: Long,
|
||||
val players: PlayersInfo,
|
||||
val gameRules: Map<String, Boolean>,
|
||||
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 firstJoin: Long,
|
||||
val lastJoin: Long,
|
||||
val lastLeave: Long,
|
||||
val isOnline: Boolean,
|
||||
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만)
|
||||
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값)
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue