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
|
package com.beemer.essentials.nickname
|
||||||
|
|
||||||
import com.mojang.brigadier.arguments.StringArgumentType
|
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.bus.api.SubscribeEvent
|
||||||
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
|
|
||||||
/**
|
/** 닉네임 명령어 /닉네임 변경 <닉네임> /닉네임 초기화 */
|
||||||
* 닉네임 명령어
|
|
||||||
* /닉네임 변경 <닉네임>
|
|
||||||
* /닉네임 초기화
|
|
||||||
*/
|
|
||||||
object NicknameCommand {
|
object NicknameCommand {
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
// 한글 명령어
|
// 한글 명령어
|
||||||
event.dispatcher.register(
|
event.dispatcher.register(
|
||||||
Commands.literal("닉네임")
|
Commands.literal("닉네임")
|
||||||
.then(
|
.then(
|
||||||
Commands.literal("변경")
|
Commands.literal("변경")
|
||||||
.then(
|
.then(
|
||||||
Commands.argument("닉네임", StringArgumentType.greedyString())
|
Commands.argument(
|
||||||
.executes { context ->
|
"닉네임",
|
||||||
val player = context.source.entity as? ServerPlayer
|
StringArgumentType
|
||||||
?: return@executes 0
|
.greedyString()
|
||||||
|
)
|
||||||
val nickname = StringArgumentType.getString(context, "닉네임").trim()
|
.executes { context ->
|
||||||
executeSet(player, nickname)
|
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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
return 0
|
||||||
.then(
|
}
|
||||||
Commands.literal("초기화")
|
|
||||||
.executes { context ->
|
// 유효성 검사: 중복
|
||||||
val player = context.source.entity as? ServerPlayer
|
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
|
||||||
?: return@executes 0
|
player.sendSystemMessage(
|
||||||
|
Component.literal("이미 사용 중인 닉네임입니다.").withStyle {
|
||||||
executeReset(player)
|
it.withColor(ChatFormatting.RED)
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 영어 명령어
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 닉네임 저장 및 적용 (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")
|
return 1
|
||||||
.executes { context ->
|
}
|
||||||
val player = context.source.entity as? ServerPlayer
|
|
||||||
?: return@executes 0
|
private fun executeReset(player: ServerPlayer): Int {
|
||||||
|
if (!NicknameDataStore.hasNickname(player.uuid)) {
|
||||||
executeReset(player)
|
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
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유효성 검사: 중복
|
|
||||||
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.LogManager
|
||||||
import org.apache.logging.log4j.Logger
|
import org.apache.logging.log4j.Logger
|
||||||
|
|
||||||
|
/** 닉네임 데이터 엔트리 - 실제 이름과 닉네임을 함께 저장 */
|
||||||
|
data class NicknameEntry(
|
||||||
|
val originalName: String, // 실제 마인크래프트 이름
|
||||||
|
val nickname: String // 설정된 닉네임
|
||||||
|
)
|
||||||
|
|
||||||
/** 닉네임 데이터 저장소 JSON 파일로 닉네임 저장/로드 */
|
/** 닉네임 데이터 저장소 JSON 파일로 닉네임 저장/로드 */
|
||||||
object NicknameDataStore {
|
object NicknameDataStore {
|
||||||
private const val MOD_ID = "essentials"
|
private const val MOD_ID = "essentials"
|
||||||
|
|
@ -22,8 +28,8 @@ object NicknameDataStore {
|
||||||
|
|
||||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
|
||||||
// UUID -> 닉네임 매핑
|
// UUID -> NicknameEntry 매핑
|
||||||
private val nicknames: MutableMap<String, String> = ConcurrentHashMap()
|
private val nicknames: MutableMap<String, NicknameEntry> = ConcurrentHashMap()
|
||||||
|
|
||||||
/** 닉네임 데이터 로드 */
|
/** 닉네임 데이터 로드 */
|
||||||
fun load() {
|
fun load() {
|
||||||
|
|
@ -32,11 +38,26 @@ object NicknameDataStore {
|
||||||
|
|
||||||
if (Files.exists(FILE_PATH)) {
|
if (Files.exists(FILE_PATH)) {
|
||||||
val json = Files.readString(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()
|
try {
|
||||||
nicknames.putAll(loaded)
|
val type = object : TypeToken<Map<String, NicknameEntry>>() {}.type
|
||||||
LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}개")
|
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) {
|
} catch (e: Exception) {
|
||||||
LOGGER.error("[Essentials] 닉네임 데이터 로드 실패", e)
|
LOGGER.error("[Essentials] 닉네임 데이터 로드 실패", e)
|
||||||
|
|
@ -54,9 +75,9 @@ object NicknameDataStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 닉네임 설정 */
|
/** 닉네임 설정 (실제 이름과 함께) */
|
||||||
fun setNickname(uuid: UUID, nickname: String) {
|
fun setNickname(uuid: UUID, originalName: String, nickname: String) {
|
||||||
nicknames[uuid.toString()] = nickname
|
nicknames[uuid.toString()] = NicknameEntry(originalName, nickname)
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +90,18 @@ object NicknameDataStore {
|
||||||
/** 닉네임 조회 */
|
/** 닉네임 조회 */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getNickname(uuid: UUID): String? {
|
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()]
|
return nicknames[uuid.toString()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +110,7 @@ object NicknameDataStore {
|
||||||
val target = nickname.trim()
|
val target = nickname.trim()
|
||||||
if (target.isEmpty()) return false
|
if (target.isEmpty()) return false
|
||||||
return nicknames.entries.any {
|
return nicknames.entries.any {
|
||||||
it.value.equals(target, ignoreCase = true) &&
|
it.value.nickname.equals(target, ignoreCase = true) &&
|
||||||
(excludeUUID == null || it.key != excludeUUID.toString())
|
(excludeUUID == null || it.key != excludeUUID.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +124,7 @@ object NicknameDataStore {
|
||||||
fun getUuidByNickname(nickname: String): UUID? {
|
fun getUuidByNickname(nickname: String): UUID? {
|
||||||
val target = nickname.trim()
|
val target = nickname.trim()
|
||||||
if (target.isEmpty()) return null
|
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) }
|
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.PlayerTracker
|
||||||
import co.caadiq.serverstatus.data.ServerDataCollector
|
import co.caadiq.serverstatus.data.ServerDataCollector
|
||||||
import co.caadiq.serverstatus.data.WorldDataCollector
|
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 co.caadiq.serverstatus.network.HttpApiServer
|
||||||
import net.minecraft.server.MinecraftServer
|
import net.minecraft.server.MinecraftServer
|
||||||
import net.neoforged.bus.api.IEventBus
|
import net.neoforged.bus.api.IEventBus
|
||||||
|
|
@ -74,6 +76,14 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
|
||||||
fun onServerStarted(event: ServerStartedEvent) {
|
fun onServerStarted(event: ServerStartedEvent) {
|
||||||
minecraftServer = event.server
|
minecraftServer = event.server
|
||||||
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 저장됨")
|
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) {
|
fun onServerStopped(event: ServerStoppedEvent) {
|
||||||
minecraftServer = null
|
minecraftServer = null
|
||||||
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 해제됨")
|
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 해제됨")
|
||||||
|
LogCaptureAppender.uninstall()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 전용 서버 설정 이벤트 */
|
/** 전용 서버 설정 이벤트 */
|
||||||
|
|
@ -96,6 +107,9 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
|
||||||
httpApiServer = HttpApiServer(config.httpPort)
|
httpApiServer = HttpApiServer(config.httpPort)
|
||||||
httpApiServer.start()
|
httpApiServer.start()
|
||||||
|
|
||||||
|
// 로그 캡쳐 Appender 설치
|
||||||
|
LogCaptureAppender.install()
|
||||||
|
|
||||||
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
|
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,33 @@
|
||||||
package co.caadiq.serverstatus.config
|
package co.caadiq.serverstatus.config
|
||||||
|
|
||||||
import co.caadiq.serverstatus.ServerStatusMod
|
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.nio.file.Files
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.io.path.exists
|
import kotlin.io.path.exists
|
||||||
import kotlin.io.path.readText
|
import kotlin.io.path.readText
|
||||||
import kotlin.io.path.writeText
|
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
|
@Serializable
|
||||||
data class ModConfig(
|
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 {
|
companion object {
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// config/serverstatus/ 폴더에 저장
|
// config/serverstatus/ 폴더에 저장
|
||||||
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
||||||
private val configPath = configDir.resolve("config.json")
|
private val configPath = configDir.resolve("config.json")
|
||||||
|
|
||||||
/**
|
/** 설정 파일 로드 (없으면 기본값으로 생성) */
|
||||||
* 설정 파일 로드 (없으면 기본값으로 생성)
|
|
||||||
*/
|
|
||||||
fun load(): ModConfig {
|
fun load(): ModConfig {
|
||||||
return try {
|
return try {
|
||||||
if (configPath.exists()) {
|
if (configPath.exists()) {
|
||||||
|
|
@ -45,10 +43,8 @@ data class ModConfig(
|
||||||
ModConfig()
|
ModConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 설정 저장 */
|
||||||
* 설정 저장
|
|
||||||
*/
|
|
||||||
fun save(config: ModConfig) {
|
fun save(config: ModConfig) {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(configDir)
|
Files.createDirectories(configDir)
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,29 @@ package co.caadiq.serverstatus.config
|
||||||
|
|
||||||
import co.caadiq.serverstatus.ServerStatusMod
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
import co.caadiq.serverstatus.data.PlayerStats
|
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 java.nio.file.Files
|
||||||
import kotlin.io.path.exists
|
import kotlin.io.path.exists
|
||||||
import kotlin.io.path.readText
|
import kotlin.io.path.readText
|
||||||
import kotlin.io.path.writeText
|
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
|
@Serializable
|
||||||
data class PlayerData(
|
data class PlayerData(
|
||||||
val uuid: String,
|
val uuid: String,
|
||||||
var name: String,
|
var name: String,
|
||||||
var firstJoin: Long = System.currentTimeMillis(),
|
var firstJoin: Long = System.currentTimeMillis(),
|
||||||
var lastJoin: Long = System.currentTimeMillis(),
|
var lastJoin: Long = System.currentTimeMillis(),
|
||||||
var lastLeave: Long = 0L,
|
var lastLeave: Long = 0L,
|
||||||
var totalPlayTimeMs: Long = 0L,
|
var totalPlayTimeMs: Long = 0L,
|
||||||
// 마지막 로그아웃 시 저장된 통계
|
// 마지막 로그아웃 시 저장된 통계
|
||||||
var savedStats: PlayerStats? = null,
|
var savedStats: PlayerStats? = null,
|
||||||
@Transient
|
@Transient var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
|
||||||
var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
|
|
||||||
) {
|
) {
|
||||||
/**
|
/** 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산 */
|
||||||
* 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산
|
|
||||||
*/
|
|
||||||
fun getCurrentSessionMs(): Long {
|
fun getCurrentSessionMs(): Long {
|
||||||
return if (isOnline) {
|
return if (isOnline) {
|
||||||
System.currentTimeMillis() - lastJoin
|
System.currentTimeMillis() - lastJoin
|
||||||
|
|
@ -39,39 +32,44 @@ data class PlayerData(
|
||||||
0L
|
0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 실시간 총 플레이타임 (ms) - 누적 + 현재 세션 */
|
||||||
* 실시간 총 플레이타임 (ms) - 누적 + 현재 세션
|
|
||||||
*/
|
|
||||||
fun getRealTimeTotalMs(): Long {
|
fun getRealTimeTotalMs(): Long {
|
||||||
return totalPlayTimeMs + getCurrentSessionMs()
|
return totalPlayTimeMs + getCurrentSessionMs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayerDataStore(
|
data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutableMapOf()) {
|
||||||
val players: MutableMap<String, PlayerData> = mutableMapOf()
|
|
||||||
) {
|
|
||||||
companion object {
|
companion object {
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// config/serverstatus/ 폴더에 저장
|
// config/serverstatus/ 폴더에 저장
|
||||||
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
||||||
private val dataPath = configDir.resolve("players.json")
|
private val dataPath = configDir.resolve("players.json")
|
||||||
|
|
||||||
// Essentials 닉네임 파일 경로
|
// 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> {
|
private fun loadEssentialsNicknames(): Map<String, String> {
|
||||||
return try {
|
return try {
|
||||||
if (essentialsNicknamePath.exists()) {
|
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 {
|
} else {
|
||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
|
|
@ -79,10 +77,29 @@ data class PlayerDataStore(
|
||||||
emptyMap()
|
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 {
|
fun load(): PlayerDataStore {
|
||||||
return try {
|
return try {
|
||||||
if (dataPath.exists()) {
|
if (dataPath.exists()) {
|
||||||
|
|
@ -96,18 +113,20 @@ data class PlayerDataStore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름) */
|
||||||
* 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름)
|
|
||||||
*/
|
|
||||||
fun getDisplayName(uuid: String): String {
|
fun getDisplayName(uuid: String): String {
|
||||||
val essentialsNicks = loadEssentialsNicknames()
|
val essentialsNicks = loadEssentialsNicknames()
|
||||||
return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown"
|
return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 특정 플레이어의 실제 마인크래프트 이름 가져오기 (Essentials에서 저장된 originalName 우선) */
|
||||||
* 모든 플레이어 닉네임 Essentials와 동기화
|
fun getActualName(uuid: String): String {
|
||||||
*/
|
val essentialsOriginalNames = loadEssentialsOriginalNames()
|
||||||
|
return essentialsOriginalNames[uuid] ?: players[uuid]?.name ?: "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모든 플레이어 닉네임 Essentials와 동기화 */
|
||||||
fun syncNicknamesFromEssentials() {
|
fun syncNicknamesFromEssentials() {
|
||||||
val essentialsNicks = loadEssentialsNicknames()
|
val essentialsNicks = loadEssentialsNicknames()
|
||||||
var synced = 0
|
var synced = 0
|
||||||
|
|
@ -121,13 +140,13 @@ data class PlayerDataStore(
|
||||||
}
|
}
|
||||||
if (synced > 0) {
|
if (synced > 0) {
|
||||||
save()
|
save()
|
||||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}명")
|
ServerStatusMod.LOGGER.info(
|
||||||
|
"[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}명"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 데이터 저장 */
|
||||||
* 데이터 저장
|
|
||||||
*/
|
|
||||||
fun save() {
|
fun save() {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(configDir)
|
Files.createDirectories(configDir)
|
||||||
|
|
@ -136,24 +155,19 @@ data class PlayerDataStore(
|
||||||
ServerStatusMod.LOGGER.error("플레이어 데이터 저장 실패: ${e.message}")
|
ServerStatusMod.LOGGER.error("플레이어 데이터 저장 실패: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 플레이어 입장 처리 */
|
||||||
* 플레이어 입장 처리
|
|
||||||
*/
|
|
||||||
fun onPlayerJoin(uuid: String, name: String) {
|
fun onPlayerJoin(uuid: String, name: String) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val player = players.getOrPut(uuid) {
|
val player =
|
||||||
PlayerData(uuid = uuid, name = name, firstJoin = now)
|
players.getOrPut(uuid) { PlayerData(uuid = uuid, name = name, firstJoin = now) }
|
||||||
}
|
|
||||||
player.name = name
|
player.name = name
|
||||||
player.lastJoin = now
|
player.lastJoin = now
|
||||||
player.isOnline = true
|
player.isOnline = true
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 플레이어 퇴장 처리 (통계 저장 포함) */
|
||||||
* 플레이어 퇴장 처리 (통계 저장 포함)
|
|
||||||
*/
|
|
||||||
fun onPlayerLeave(uuid: String, stats: PlayerStats?) {
|
fun onPlayerLeave(uuid: String, stats: PlayerStats?) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
players[uuid]?.let { player ->
|
players[uuid]?.let { player ->
|
||||||
|
|
@ -164,29 +178,23 @@ data class PlayerDataStore(
|
||||||
// 통계 저장
|
// 통계 저장
|
||||||
if (stats != null) {
|
if (stats != null) {
|
||||||
player.savedStats = stats
|
player.savedStats = stats
|
||||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}")
|
ServerStatusMod.LOGGER.info(
|
||||||
|
"[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 플레이어 정보 조회 */
|
||||||
* 플레이어 정보 조회
|
|
||||||
*/
|
|
||||||
fun getPlayer(uuid: String): PlayerData? = players[uuid]
|
fun getPlayer(uuid: String): PlayerData? = players[uuid]
|
||||||
|
|
||||||
/**
|
/** 전체 플레이어 목록 조회 */
|
||||||
* 전체 플레이어 목록 조회
|
|
||||||
*/
|
|
||||||
fun getAllPlayers(): List<PlayerData> = players.values.toList().sortedBy { it.name }
|
fun getAllPlayers(): List<PlayerData> = players.values.toList().sortedBy { it.name }
|
||||||
|
|
||||||
/**
|
/** 플레이어 온라인 상태 확인 */
|
||||||
* 플레이어 온라인 상태 확인
|
|
||||||
*/
|
|
||||||
fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false
|
fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false
|
||||||
|
|
||||||
/**
|
/** 저장된 통계 조회 */
|
||||||
* 저장된 통계 조회
|
|
||||||
*/
|
|
||||||
fun getSavedStats(uuid: String): PlayerStats? = players[uuid]?.savedStats
|
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.bus.api.SubscribeEvent
|
||||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent
|
||||||
|
|
||||||
/**
|
/** 플레이어 이벤트 추적기 입장/퇴장 이벤트를 감지하여 데이터 저장 */
|
||||||
* 플레이어 이벤트 추적기
|
|
||||||
* 입장/퇴장 이벤트를 감지하여 데이터 저장
|
|
||||||
*/
|
|
||||||
object PlayerTracker {
|
object PlayerTracker {
|
||||||
|
|
||||||
/**
|
/** 플레이어 입장 이벤트 */
|
||||||
* 플레이어 입장 이벤트
|
|
||||||
*/
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
fun onPlayerJoin(event: PlayerEvent.PlayerLoggedInEvent) {
|
fun onPlayerJoin(event: PlayerEvent.PlayerLoggedInEvent) {
|
||||||
val player = event.entity
|
val player = event.entity
|
||||||
val uuid = player.stringUUID
|
val uuid = player.stringUUID
|
||||||
val name = player.name.string
|
val name = player.name.string
|
||||||
|
|
||||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 입장: $name ($uuid)")
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 입장: $name ($uuid)")
|
||||||
|
|
||||||
// 플레이어 데이터 저장
|
// 플레이어 데이터 저장
|
||||||
ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name)
|
ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 플레이어 퇴장 이벤트 */
|
||||||
* 플레이어 퇴장 이벤트
|
|
||||||
*/
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) {
|
fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) {
|
||||||
val player = event.entity
|
val player = event.entity
|
||||||
val uuid = player.stringUUID
|
val uuid = player.stringUUID
|
||||||
val name = player.name.string
|
val name = player.name.string
|
||||||
|
|
||||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)")
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)")
|
||||||
|
|
||||||
// 통계 수집 후 저장
|
// 통계 수집 후 저장
|
||||||
val stats = try {
|
val stats =
|
||||||
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
try {
|
||||||
} catch (e: Exception) {
|
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
||||||
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}")
|
} catch (e: Exception) {
|
||||||
null
|
ServerStatusMod.LOGGER.error(
|
||||||
}
|
"[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}"
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
// 플레이어 데이터 및 통계 저장
|
// 플레이어 데이터 및 통계 저장
|
||||||
ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats)
|
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
|
package co.caadiq.serverstatus.network
|
||||||
|
|
||||||
import co.caadiq.serverstatus.ServerStatusMod
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
|
import co.caadiq.serverstatus.data.LogCollector
|
||||||
|
import co.caadiq.serverstatus.data.LogEntry
|
||||||
import co.caadiq.serverstatus.data.WorldInfo
|
import co.caadiq.serverstatus.data.WorldInfo
|
||||||
import com.sun.net.httpserver.HttpExchange
|
import com.sun.net.httpserver.HttpExchange
|
||||||
import com.sun.net.httpserver.HttpHandler
|
import com.sun.net.httpserver.HttpHandler
|
||||||
|
|
@ -35,6 +37,10 @@ class HttpApiServer(private val port: Int) {
|
||||||
server?.createContext("/player", PlayerHandler())
|
server?.createContext("/player", PlayerHandler())
|
||||||
server?.createContext("/worlds", WorldsHandler())
|
server?.createContext("/worlds", WorldsHandler())
|
||||||
server?.createContext("/command", CommandHandler())
|
server?.createContext("/command", CommandHandler())
|
||||||
|
server?.createContext("/logs", LogsHandler())
|
||||||
|
server?.createContext("/logfiles", LogFilesHandler())
|
||||||
|
server?.createContext("/logfile", LogFileDownloadHandler())
|
||||||
|
server?.createContext("/banlist", BanlistHandler())
|
||||||
|
|
||||||
server?.start()
|
server?.start()
|
||||||
ServerStatusMod.LOGGER.info(
|
ServerStatusMod.LOGGER.info(
|
||||||
|
|
@ -66,13 +72,43 @@ class HttpApiServer(private val port: Int) {
|
||||||
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
|
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
|
||||||
// Essentials에서 닉네임 조회 (없으면 저장된 이름 사용)
|
// Essentials에서 닉네임 조회 (없으면 저장된 이름 사용)
|
||||||
val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid)
|
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(
|
return PlayerDetail(
|
||||||
uuid = player.uuid,
|
uuid = player.uuid,
|
||||||
name = displayName,
|
name = actualName,
|
||||||
|
displayName = displayName,
|
||||||
firstJoin = player.firstJoin,
|
firstJoin = player.firstJoin,
|
||||||
lastJoin = player.lastJoin,
|
lastJoin = player.lastJoin,
|
||||||
lastLeave = player.lastLeave,
|
lastLeave = player.lastLeave,
|
||||||
isOnline = player.isOnline,
|
isOnline = player.isOnline,
|
||||||
|
isOp = isOp,
|
||||||
currentSessionMs = player.getCurrentSessionMs(),
|
currentSessionMs = player.getCurrentSessionMs(),
|
||||||
totalPlayTimeMs = player.totalPlayTimeMs
|
totalPlayTimeMs = player.totalPlayTimeMs
|
||||||
)
|
)
|
||||||
|
|
@ -253,9 +289,8 @@ class HttpApiServer(private val port: Int) {
|
||||||
try {
|
try {
|
||||||
val commandSource = server.createCommandSourceStack()
|
val commandSource = server.createCommandSourceStack()
|
||||||
server.commands.performPrefixedCommand(commandSource, cleanCommand)
|
server.commands.performPrefixedCommand(commandSource, cleanCommand)
|
||||||
ServerStatusMod.LOGGER.info("[Admin] 명령어 실행: $cleanCommand")
|
|
||||||
} catch (e: Exception) {
|
} 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 WorldsResponse(val worlds: List<WorldInfo>)
|
||||||
|
|
||||||
@Serializable data class CommandRequest(val command: String)
|
@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
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/** 서버 상태 */
|
||||||
* 서버 상태
|
|
||||||
*/
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ServerStatus(
|
data class ServerStatus(
|
||||||
val online: Boolean,
|
val online: Boolean,
|
||||||
val version: String,
|
val version: String,
|
||||||
val modLoader: String,
|
val modLoader: String,
|
||||||
val difficulty: String,
|
val difficulty: String,
|
||||||
val uptimeMinutes: Long,
|
val uptimeMinutes: Long,
|
||||||
val players: PlayersInfo,
|
val players: PlayersInfo,
|
||||||
val gameRules: Map<String, Boolean>,
|
val gameRules: Map<String, Boolean>,
|
||||||
val mods: List<ModInfo>
|
val mods: List<ModInfo>
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** 플레이어 정보 */
|
||||||
* 플레이어 정보
|
|
||||||
*/
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayersInfo(
|
data class PlayersInfo(val current: Int, val max: Int, val online: List<OnlinePlayer>)
|
||||||
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
|
@Serializable
|
||||||
data class PlayerDetail(
|
data class PlayerDetail(
|
||||||
val uuid: String,
|
val uuid: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val firstJoin: Long,
|
val displayName: String, // Essentials 닉네임 (없으면 name과 동일)
|
||||||
val lastJoin: Long,
|
val firstJoin: Long,
|
||||||
val lastLeave: Long,
|
val lastJoin: Long,
|
||||||
val isOnline: Boolean,
|
val lastLeave: Long,
|
||||||
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만)
|
val isOnline: Boolean,
|
||||||
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값)
|
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