451 lines
20 KiB
Java
451 lines
20 KiB
Java
package com.example.playertime;
|
||
|
||
import com.google.gson.*;
|
||
import com.sun.net.httpserver.HttpServer;
|
||
import com.sun.net.httpserver.HttpHandler;
|
||
import com.sun.net.httpserver.HttpExchange;
|
||
import net.fabricmc.loader.api.FabricLoader;
|
||
import net.minecraft.server.MinecraftServer;
|
||
import net.minecraft.server.PlayerManager;
|
||
import net.minecraft.server.network.ServerPlayerEntity;
|
||
|
||
import java.io.*;
|
||
import java.lang.management.ManagementFactory;
|
||
import java.net.InetSocketAddress;
|
||
import java.net.URL;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.nio.file.*;
|
||
import java.util.*;
|
||
import java.util.concurrent.*;
|
||
import java.util.stream.Collectors;
|
||
|
||
// 导入API类,别忘了
|
||
import com.example.playertime.api.PlayerStatsResponse;
|
||
import com.example.playertime.api.ServerStatusResponse;
|
||
import com.example.playertime.api.OnlinePlayersResponse;
|
||
import com.example.playertime.api.PlayerCountResponse;
|
||
import com.example.playertime.api.WhitelistResponse;
|
||
|
||
|
||
public class WebServer {
|
||
private final HttpServer server;
|
||
private final PlayerTimeTracker timeTracker;
|
||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||
private final ExecutorService executor = Executors.newFixedThreadPool(4);
|
||
private final MinecraftServer minecraftServer;
|
||
|
||
private static final Map<String, String> MIME_TYPES = Map.ofEntries(
|
||
Map.entry("html", "text/html"),
|
||
Map.entry("css", "text/css"),
|
||
Map.entry("js", "application/javascript"),
|
||
Map.entry("json", "application/json"),
|
||
Map.entry("png", "image/png"),
|
||
Map.entry("jpg", "image/jpeg"),
|
||
Map.entry("jpeg", "image/jpeg"),
|
||
Map.entry("gif", "image/gif"),
|
||
Map.entry("svg", "image/svg+xml"),
|
||
Map.entry("woff", "application/font-woff"),
|
||
Map.entry("woff2", "application/font-woff2"),
|
||
Map.entry("ttf", "application/font-sfnt"),
|
||
Map.entry("eot", "application/vnd.ms-fontobject"),
|
||
Map.entry("ico", "image/x-icon") // 添加 favicon.ico
|
||
);
|
||
|
||
public WebServer(PlayerTimeTracker timeTracker, int port, MinecraftServer minecraftServer) throws IOException {
|
||
this.minecraftServer = minecraftServer;
|
||
if (port < 1 || port > 65535) {
|
||
throw new IllegalArgumentException("Invalid port number: " + port);
|
||
}
|
||
this.timeTracker = timeTracker;
|
||
this.server = HttpServer.create(new InetSocketAddress(port), 0);
|
||
setupContexts();
|
||
}
|
||
|
||
// 创建HTTP服务器
|
||
private void setupContexts() {
|
||
// API: 获取玩家统计数据
|
||
server.createContext("/api/stats", exchange -> {
|
||
handleCors(exchange);
|
||
if ("OPTIONS".equals(exchange.getRequestMethod())) {
|
||
exchange.sendResponseHeaders(204, -1); // CORS 预检请求
|
||
return;
|
||
}
|
||
|
||
if (!"GET".equals(exchange.getRequestMethod())) {
|
||
sendResponse(exchange, 405, "Method Not Allowed");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
List<PlayerTimeTracker.PlayerTimeStatsWithNames> statsList = timeTracker.getSortedPlayerStats();
|
||
|
||
List<PlayerStatsResponse> responseList = statsList.stream()
|
||
.map(stats -> new PlayerStatsResponse(
|
||
stats.playerName,
|
||
stats.uuid.toString(),
|
||
stats.getTotalTime(),
|
||
PlayerTimeTracker.formatTime(stats.getTotalTime()),
|
||
stats.last30Days,
|
||
PlayerTimeTracker.formatTime(stats.last30Days),
|
||
stats.last7Days,
|
||
PlayerTimeTracker.formatTime(stats.last7Days)
|
||
))
|
||
.collect(Collectors.toList());
|
||
|
||
String response = GSON.toJson(responseList);
|
||
sendResponse(exchange, 200, response.getBytes(StandardCharsets.UTF_8), "application/json");
|
||
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/stats 请求成功,返回 {} 条玩家数据。", responseList.size());
|
||
} catch (Exception e) {
|
||
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/stats 请求失败", e);
|
||
sendResponse(exchange, 500, "Internal Server Error");
|
||
}
|
||
});
|
||
|
||
// API: 获取语言文件内容,不公开
|
||
server.createContext("/api/lang", exchange -> {
|
||
handleCors(exchange);
|
||
if ("OPTIONS".equals(exchange.getRequestMethod())) {
|
||
exchange.sendResponseHeaders(204, -1);
|
||
return;
|
||
}
|
||
|
||
if (!"GET".equals(exchange.getRequestMethod())) {
|
||
sendResponse(exchange, 405, "Method Not Allowed");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
Map<String, String> langMap = PlayerTimeMod.getLocalizationManager().getAllStrings();
|
||
String response = GSON.toJson(langMap);
|
||
sendResponse(exchange, 200, response.getBytes(StandardCharsets.UTF_8), "application/json");
|
||
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/lang 请求成功,返回 {} 条语言字符串。", langMap.size());
|
||
} catch (Exception e) {
|
||
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/lang 请求失败", e);
|
||
sendResponse(exchange, 500, "Internal Server Error");
|
||
}
|
||
});
|
||
|
||
// API: 获取在线玩家列表
|
||
server.createContext("/api/online-players", exchange -> {
|
||
handleCors(exchange);
|
||
if ("OPTIONS".equals(exchange.getRequestMethod())) {
|
||
exchange.sendResponseHeaders(204, -1);
|
||
return;
|
||
}
|
||
|
||
if (!"GET".equals(exchange.getRequestMethod())) {
|
||
sendResponse(exchange, 405, "Method Not Allowed");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
Map<String, List<Map<String, String>>> onlinePlayers = timeTracker.getOnlinePlayersCategorized();
|
||
OnlinePlayersResponse response = new OnlinePlayersResponse(onlinePlayers.get("whitelisted"), onlinePlayers.get("non_whitelisted"));
|
||
sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json");
|
||
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/online-players 请求成功。");
|
||
} catch (Exception e) {
|
||
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/online-players 请求失败", e);
|
||
sendResponse(exchange, 500, "Internal Server Error");
|
||
}
|
||
});
|
||
|
||
// API: 获取在线玩家数量
|
||
server.createContext("/api/player-count", exchange -> {
|
||
handleCors(exchange);
|
||
if ("OPTIONS".equals(exchange.getRequestMethod())) {
|
||
exchange.sendResponseHeaders(204, -1);
|
||
return;
|
||
}
|
||
|
||
if (!"GET".equals(exchange.getRequestMethod())) {
|
||
sendResponse(exchange, 405, "Method Not Allowed");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
Map<String, Integer> counts = timeTracker.getOnlinePlayerCounts();
|
||
PlayerCountResponse response = new PlayerCountResponse(counts.get("total"), counts.get("whitelisted"), counts.get("non_whitelisted"));
|
||
sendResponse(exchange, 200, GSON.toJson(response).getBytes(StandardCharsets.UTF_8), "application/json");
|
||
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/player-count 请求成功。");
|
||
} catch (Exception e) {
|
||
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/player-count 请求失败", e);
|
||
sendResponse(exchange, 500, "Internal Server Error");
|
||
}
|
||
});
|
||
|
||
// API: 获取白名单列表
|
||
server.createContext("/api/whitelist", exchange -> {
|
||
handleCors(exchange);
|
||
if ("OPTIONS".equals(exchange.getRequestMethod())) {
|
||
exchange.sendResponseHeaders(204, -1);
|
||
return;
|
||
}
|
||
|
||
if (!"GET".equals(exchange.getRequestMethod())) {
|
||
sendResponse(exchange, 405, "Method Not Allowed");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
List<Map<String, Object>> whitelist = timeTracker.getWhitelistPlayers();
|
||
List<WhitelistResponse> responseList = whitelist.stream()
|
||
.map(map -> new WhitelistResponse((String)map.get("name"), (String)map.get("uuid"), (Boolean)map.get("online")))
|
||
.collect(Collectors.toList());
|
||
sendResponse(exchange, 200, GSON.toJson(responseList).getBytes(StandardCharsets.UTF_8), "application/json");
|
||
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/whitelist 请求成功,返回 {} 条白名单记录。", responseList.size());
|
||
} catch (Exception e) {
|
||
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/whitelist 请求失败", e);
|
||
sendResponse(exchange, 500, "Internal Server Error");
|
||
}
|
||
});
|
||
|
||
|
||
// API: 获取服务器状态
|
||
server.createContext("/api/server-status", exchange -> {
|
||
handleCors(exchange);
|
||
if ("OPTIONS".equals(exchange.getRequestMethod())) {
|
||
exchange.sendResponseHeaders(204, -1);
|
||
return;
|
||
}
|
||
|
||
if (!"GET".equals(exchange.getRequestMethod())) {
|
||
sendResponse(exchange, 405, "Method Not Allowed");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
Runtime runtime = Runtime.getRuntime();
|
||
ServerStatusResponse status = new ServerStatusResponse();
|
||
|
||
long maxMemory = runtime.maxMemory();
|
||
long totalMemory = runtime.totalMemory();
|
||
long freeMemory = runtime.freeMemory();
|
||
long usedMemory = totalMemory - freeMemory;
|
||
|
||
status.memory.max = maxMemory;
|
||
status.memory.total = totalMemory;
|
||
status.memory.used = usedMemory;
|
||
status.memory.free = freeMemory;
|
||
status.memory.usage_percentage = (double) usedMemory / maxMemory * 100;
|
||
|
||
status.available_processors = runtime.availableProcessors();
|
||
|
||
long uptimeMillis = ManagementFactory.getRuntimeMXBean().getUptime();
|
||
status.uptime = uptimeMillis / 1000; // 秒
|
||
status.uptime_formatted = formatUptime(uptimeMillis);
|
||
|
||
try {
|
||
File diskPartition = new File(".");
|
||
status.disk.total = diskPartition.getTotalSpace();
|
||
status.disk.free = diskPartition.getFreeSpace();
|
||
status.disk.usable = diskPartition.getUsableSpace();
|
||
status.disk.usage_percentage = (double) (status.disk.total - status.disk.free) / status.disk.total * 100;
|
||
} catch (Exception e) {
|
||
PlayerTimeMod.LOGGER.warn("[在线时间] 无法获取磁盘信息", e);
|
||
}
|
||
|
||
|
||
status.server.version = minecraftServer.getVersion();
|
||
status.server.player_count = minecraftServer.getCurrentPlayerCount();
|
||
status.server.max_players = minecraftServer.getMaxPlayerCount();
|
||
status.server.motd = minecraftServer.getServerMotd();
|
||
|
||
status.server.average_tick_time_ms = minecraftServer.getAverageTickTime();
|
||
long[] tickTimes = minecraftServer.getTickTimes();
|
||
if (tickTimes != null && tickTimes.length > 0) {
|
||
long[] recentTicksCopy = Arrays.copyOf(tickTimes, tickTimes.length);
|
||
status.server.recent_tick_samples_ms = Arrays.stream(recentTicksCopy)
|
||
.mapToDouble(tick -> tick / 1000000.0)
|
||
.boxed()
|
||
.collect(Collectors.toList());
|
||
} else {
|
||
status.server.recent_tick_samples_ms = Collections.emptyList();
|
||
}
|
||
|
||
|
||
sendResponse(exchange, 200, GSON.toJson(status).getBytes(StandardCharsets.UTF_8), "application/json");
|
||
PlayerTimeMod.LOGGER.debug("[在线时间] API /api/server-status 请求成功。");
|
||
} catch (Exception e) {
|
||
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/server-status 请求失败", e);
|
||
sendResponse(exchange, 500, "Internal Server Error");
|
||
}
|
||
});
|
||
|
||
// API: 获取原始玩家数据文件内容,建议最好别公开
|
||
server.createContext("/api/playerdata", exchange -> {
|
||
handleCors(exchange);
|
||
if ("OPTIONS".equals(exchange.getRequestMethod())) {
|
||
exchange.sendResponseHeaders(204, -1);
|
||
return;
|
||
}
|
||
|
||
if (!"GET".equals(exchange.getRequestMethod())) {
|
||
sendResponse(exchange, 405, "Method Not Allowed");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
Path dataFile = timeTracker.getDataFile();
|
||
|
||
if (!Files.exists(dataFile)) {
|
||
PlayerTimeMod.LOGGER.warn("[在线时间] 玩家数据文件未找到: {}", dataFile);
|
||
sendResponse(exchange, 404, "Data file not found");
|
||
return;
|
||
}
|
||
|
||
byte[] fileContent = Files.readAllBytes(dataFile);
|
||
|
||
sendResponse(exchange, 200, fileContent, "application/json");
|
||
PlayerTimeMod.LOGGER.debug("[在线时间] 成功提供玩家数据文件 {}", dataFile);
|
||
|
||
} catch (IOException e) {
|
||
PlayerTimeMod.LOGGER.error("[在线时间] 读取玩家数据文件时出错", e);
|
||
sendResponse(exchange, 500, "Error reading data file");
|
||
} catch (Exception e) {
|
||
PlayerTimeMod.LOGGER.error("[在线时间] 处理 /api/playerdata 请求时发生未知错误", e);
|
||
sendResponse(exchange, 500, "Internal Server Error");
|
||
}
|
||
});
|
||
|
||
|
||
// 静态文件服务
|
||
server.createContext("/", exchange -> {
|
||
try {
|
||
String requestPath = exchange.getRequestURI().getPath();
|
||
String resourcePath = "assets/playertime/web" + requestPath;
|
||
|
||
if (requestPath.equals("/")) {
|
||
resourcePath += "index.html";
|
||
}
|
||
|
||
if (resourcePath.contains("..")) {
|
||
sendResponse(exchange, 403, "Forbidden");
|
||
return;
|
||
}
|
||
|
||
String finalResourcePath = resourcePath;
|
||
InputStream is = FabricLoader.getInstance().getModContainer("playertime")
|
||
.flatMap(container -> container.findPath(finalResourcePath))
|
||
.map(p -> {
|
||
try {
|
||
return p.toUri().toURL().openStream();
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
})
|
||
.orElse(null);
|
||
|
||
|
||
if (is == null) {
|
||
if (!requestPath.equals("/") && !requestPath.contains(".")) {
|
||
PlayerTimeMod.LOGGER.debug("[在线时间] 找不到静态资源:{},尝试提供 index.html 作为 fallback。", resourcePath);
|
||
resourcePath = "assets/playertime/web/index.html";
|
||
String finalResourcePath1 = resourcePath;
|
||
is = FabricLoader.getInstance().getModContainer("playertime")
|
||
.flatMap(container -> container.findPath(finalResourcePath1))
|
||
.map(p -> {
|
||
try {
|
||
return p.toUri().toURL().openStream();
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
})
|
||
.orElse(null);
|
||
}
|
||
|
||
if (is == null) {
|
||
PlayerTimeMod.LOGGER.warn("[在线时间] 找不到静态资源: {}", finalResourcePath);
|
||
sendResponse(exchange, 404, "Not Found");
|
||
return;
|
||
}
|
||
}
|
||
|
||
String fileName = Paths.get(resourcePath).getFileName().toString();
|
||
String extension = "";
|
||
int dotIndex = fileName.lastIndexOf('.');
|
||
if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
|
||
extension = fileName.substring(dotIndex + 1).toLowerCase();
|
||
}
|
||
String contentType = MIME_TYPES.getOrDefault(extension, "application/octet-stream");
|
||
|
||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||
byte[] data = new byte[4096];
|
||
int nRead;
|
||
while ((nRead = is.read(data, 0, data.length)) != -1) {
|
||
buffer.write(data, 0, nRead);
|
||
}
|
||
buffer.flush();
|
||
is.close();
|
||
|
||
sendResponse(exchange, 200, buffer.toByteArray(), contentType);
|
||
PlayerTimeMod.LOGGER.debug("[在线时间] 提供静态文件: {}", resourcePath);
|
||
|
||
} catch (Exception e) {
|
||
PlayerTimeMod.LOGGER.error("[在线时间] 无法提供静态资源", e);
|
||
sendResponse(exchange, 500, "Internal Server Error");
|
||
}
|
||
});
|
||
|
||
|
||
server.setExecutor(executor);
|
||
}
|
||
|
||
// 跨域处理
|
||
private void handleCors(HttpExchange exchange) {
|
||
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
|
||
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type");
|
||
}
|
||
|
||
// 发送响应
|
||
private void sendResponse(HttpExchange exchange, int code, String response) throws IOException {
|
||
sendResponse(exchange, code, response.getBytes(StandardCharsets.UTF_8), "text/plain");
|
||
}
|
||
|
||
private void sendResponse(HttpExchange exchange, int code, byte[] response, String contentType) throws IOException {
|
||
exchange.getResponseHeaders().set("Content-Type", contentType);
|
||
exchange.sendResponseHeaders(code, response.length);
|
||
try (OutputStream os = exchange.getResponseBody()) {
|
||
os.write(response);
|
||
}
|
||
}
|
||
|
||
// 启动
|
||
public void start() {
|
||
server.start();
|
||
}
|
||
|
||
// 停止
|
||
public void stop() {
|
||
server.stop(3);
|
||
executor.shutdown();
|
||
try {
|
||
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
|
||
executor.shutdownNow(); // 强制关闭
|
||
}
|
||
} catch (InterruptedException e) {
|
||
executor.shutdownNow();
|
||
Thread.currentThread().interrupt();
|
||
}
|
||
}
|
||
|
||
// 格式化时间
|
||
private String formatUptime(long millis) {
|
||
long seconds = millis / 1000;
|
||
long days = seconds / 86400;
|
||
seconds %= 86400;
|
||
long hours = seconds / 3600;
|
||
seconds %= 3600;
|
||
long minutes = seconds / 60;
|
||
seconds %= 60;
|
||
|
||
StringBuilder sb = new StringBuilder();
|
||
if (days > 0) sb.append(days).append("天 ");
|
||
if (hours > 0 || days > 0) sb.append(String.format("%02d小时 ", hours));
|
||
if (minutes > 0 || hours > 0 || days > 0) sb.append(String.format("%02d分钟 ", minutes));
|
||
sb.append(String.format("%02d秒", seconds));
|
||
|
||
return sb.toString().trim();
|
||
}
|
||
}
|