From 9efd1cb851d2c12e763d5faaa993f85b6e70b18b Mon Sep 17 00:00:00 2001 From: yhy <1763917516@qq.com> Date: Fri, 5 Jun 2026 03:56:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 16 + README.md | 222 +++++++++ pom.xml | 58 +++ .../java/com/yaohun/containerlog/CLMain.java | 110 +++++ .../containerlog/command/AclCommand.java | 283 ++++++++++++ .../containerlog/config/PluginConfig.java | 95 ++++ .../database/DatabaseManager.java | 436 ++++++++++++++++++ .../containerlog/database/QueryCallback.java | 8 + .../listener/ContainerListener.java | 187 ++++++++ .../containerlog/model/ContainerAction.java | 16 + .../containerlog/model/ContainerSession.java | 52 +++ .../yaohun/containerlog/model/ItemChange.java | 28 ++ .../yaohun/containerlog/model/LogRecord.java | 140 ++++++ .../yaohun/containerlog/model/PageResult.java | 35 ++ .../util/ChestLocationResolver.java | 113 +++++ .../containerlog/util/ItemDiffUtil.java | 84 ++++ .../containerlog/util/ItemStackUtil.java | 96 ++++ .../yaohun/containerlog/util/TimeUtil.java | 25 + src/main/resources/config.yml | 51 ++ src/main/resources/plugin.yml | 16 + 20 files changed, 2071 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/com/yaohun/containerlog/CLMain.java create mode 100644 src/main/java/com/yaohun/containerlog/command/AclCommand.java create mode 100644 src/main/java/com/yaohun/containerlog/config/PluginConfig.java create mode 100644 src/main/java/com/yaohun/containerlog/database/DatabaseManager.java create mode 100644 src/main/java/com/yaohun/containerlog/database/QueryCallback.java create mode 100644 src/main/java/com/yaohun/containerlog/listener/ContainerListener.java create mode 100644 src/main/java/com/yaohun/containerlog/model/ContainerAction.java create mode 100644 src/main/java/com/yaohun/containerlog/model/ContainerSession.java create mode 100644 src/main/java/com/yaohun/containerlog/model/ItemChange.java create mode 100644 src/main/java/com/yaohun/containerlog/model/LogRecord.java create mode 100644 src/main/java/com/yaohun/containerlog/model/PageResult.java create mode 100644 src/main/java/com/yaohun/containerlog/util/ChestLocationResolver.java create mode 100644 src/main/java/com/yaohun/containerlog/util/ItemDiffUtil.java create mode 100644 src/main/java/com/yaohun/containerlog/util/ItemStackUtil.java create mode 100644 src/main/java/com/yaohun/containerlog/util/TimeUtil.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d0414d --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +target/ +out/ +lib/ +libs/ +.vscode/ +.codex/ +.idea/ +*.iml +*.class +*.log +logs/ +*.db +*.sqlite +*.sqlite3 +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..40d66f2 --- /dev/null +++ b/README.md @@ -0,0 +1,222 @@ +# AuroraContainerLog + +AuroraContainerLog 是一个面向 Spigot 1.12.2 的箱子容器日志插件,用于记录指定世界内玩家对箱子类容器的物品放入和取出行为,适合地皮服排查箱子物品被盗问题。 + +当前插件版本:`1.1.5` + +## 1. 功能概览 + +- 支持指定世界监听箱子容器。 +- 支持普通箱子、陷阱箱、大箱子记录与查询。 +- 支持记录玩家名称和 UUID。 +- 支持记录容器坐标:`world/x/y/z`。 +- 支持记录动作:`TAKE`、`PUT`。 +- 支持记录物品类型、数量、展示名、lore、附魔和完整 ItemStack Base64。 +- 支持 SQLite 存储。 +- 支持自动清理超过指定保留天数的历史数据。 +- 支持按玩家、指定箱子、附近范围分页查询。 +- 数据库写入和查询通过异步队列执行,避免阻塞主线程。 + +## 2. 运行环境 + +```text +Java: 8 +服务端: Spigot 1.12.2 +构建工具: Maven +数据库: SQLite +``` + +运行依赖: + +```text +HikariCP +sqlite-jdbc +``` + +插件 JAR 不内置 `HikariCP` 和 `sqlite-jdbc`。运行环境必须通过服务端公共依赖、启动 classpath、依赖插件或统一依赖加载方案提供这两个依赖。 + +## 3. 安装方式 + +构建插件: + +```bash +mvn package +``` + +构建完成后,将生成的插件包放入服务端: + +```text +plugins/AuroraContainerLog-1.1.5.jar +``` + +启动服务端后,插件会生成默认配置文件: + +```text +plugins/AuroraContainerLog/config.yml +``` + +## 4. 配置文件 + +默认配置: + +```yml +listen-worlds: + - world + +query: + page-size: 8 + chest-recent-days: 30 + max-near-radius: 50 + default-page: 1 + +database: + file: containerlog.db + +cleanup: + enabled: true + retention-days: 60 + interval-hours: 24 + +message-prefix: "&8[&bACL&8] &7" +``` + +配置说明: + +- `listen-worlds`:启用箱子日志监听的世界列表。 +- `query.page-size`:每页显示的日志数量。 +- `query.chest-recent-days`:`/acl chest` 默认查询最近多少天的记录。 +- `query.max-near-radius`:`/acl near` 允许的最大查询半径。 +- `query.default-page`:未填写页码时使用的默认页。 +- `database.file`:SQLite 数据库文件名。 +- `cleanup.enabled`:是否启用过期日志自动清理。 +- `cleanup.retention-days`:日志保留天数,超过后自动清理。 +- `cleanup.interval-hours`:自动清理执行间隔。 +- `message-prefix`:命令消息前缀。 + +## 5. 记录内容 + +每条日志会记录以下字段: + +```text +时间 +世界 +容器坐标 x/y/z +玩家名称 +玩家 UUID +动作 TAKE / PUT +物品 material +物品 amount +物品 displayName +物品 lore +物品 enchantments +完整 ItemStack Base64 +``` + +插件采用打开容器时快照、关闭容器时对比差异的方式记录变化,只记录箱子容器库存变化,不记录玩家背包自身变化。 + +## 6. 命令 + +管理命令需要权限: + +```text +auroracontainerlog.admin +``` + +重载配置: + +```text +/acl reload +``` + +查询指定玩家最近记录: + +```text +/acl player <玩家名> [天数] [页码] +``` + +查询当前看着的箱子最近记录: + +```text +/acl chest [页码] +``` + +查询附近箱子记录: + +```text +/acl near <半径> [天数] [页码] +``` + +命令别名: + +```text +/aclog +``` + +## 7. 权限 + +```text +auroracontainerlog.admin +``` + +默认权限: + +```text +op +``` + +## 8. 数据库说明 + +当前版本仅支持 SQLite。 + +SQLite 使用 HikariCP 单连接池: + +```text +maximumPoolSize: 1 +minimumIdle: 1 +connectionTimeout: 10000 +validationTimeout: 3000 +idleTimeout: 600000 +maxLifetime: 1700000 +``` + +连接池大小固定为 1,避免 SQLite 多连接写入导致数据库锁竞争。 + +## 9. 自动清理规则 + +插件启动后会排队执行一次过期数据清理,之后按 `cleanup.interval-hours` 周期执行。 + +默认规则: + +```text +保留最近 60 天日志 +每 24 小时清理一次 +``` + +清理操作进入数据库异步执行队列,不在主线程直接执行 SQL。 + +## 10. 查询行为 + +- `/acl chest` 会查询玩家准星指向的箱子。 +- 大箱子查询会同时匹配相邻两侧箱子坐标。 +- 玩家执行查询时,结果支持点击传送到记录坐标。 +- 控制台查询会显示完整坐标文本。 +- 查询结果按时间倒序分页显示。 + +## 11. 使用流程 + +1. 确认服务端运行环境已提供 `HikariCP` 和 `sqlite-jdbc`。 +2. 将插件 JAR 放入服务端 `plugins` 目录。 +3. 启动服务端生成默认配置。 +4. 修改 `listen-worlds`,填入需要监听的世界。 +5. 按需调整分页、自动清理和数据库文件配置。 +6. 执行 `/acl reload` 重载配置。 +7. 使用 `/acl chest`、`/acl player`、`/acl near` 查询日志。 + +## 12. 注意事项 + +- 插件包不包含 `sqlite-jdbc` 和 `HikariCP`,缺少运行依赖会导致 SQLite 连接池初始化失败。 +- 修改监听世界后,只影响后续容器操作记录,不会修改历史数据。 +- 关闭容器时只记录最终净变化,不记录打开期间每一次中间点击动作。 +- 若玩家取出物品后又在关闭前放回相同数量,最终差异为 0,不会写入日志。 +- 发布新版本前应确认 `plugin.yml` 中版本号已递增。 +- 上线前建议在测试服验证普通点击、shift-click、数字键、拖拽、大箱子和未监听世界场景。 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e43a55d --- /dev/null +++ b/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.yaohun + AuroraContainerLog + 1.1.5 + + + 8 + 8 + UTF-8 + + + + + public-rpg + https://repo.aurora-pixels.com/repository/public-rpg/ + + + spigot-repo + https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + + + + + org.spigotmc + spigot-api + 1.12.2-R0.1-SNAPSHOT + provided + + + com.zaxxer + HikariCP + 4.0.3 + + + + + AuroraContainerLog-${project.version} + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 8 + 8 + UTF-8 + + + + + + diff --git a/src/main/java/com/yaohun/containerlog/CLMain.java b/src/main/java/com/yaohun/containerlog/CLMain.java new file mode 100644 index 0000000..c505f9e --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/CLMain.java @@ -0,0 +1,110 @@ +package com.yaohun.containerlog; + +import com.yaohun.containerlog.command.AclCommand; +import com.yaohun.containerlog.config.PluginConfig; +import com.yaohun.containerlog.database.DatabaseManager; +import com.yaohun.containerlog.listener.ContainerListener; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.event.HandlerList; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.plugin.java.JavaPlugin; + +public class CLMain extends JavaPlugin { + + private static CLMain instance; + private PluginConfig pluginConfig; + private DatabaseManager databaseManager; + private ContainerListener containerListener; + private BukkitTask cleanupTask; + + @Override + public void onEnable() { + instance = this; + saveDefaultConfig(); + + Bukkit.getConsoleSender().sendMessage("§f[§6!§f] §aAuroraContainerLog §f开始加载"); + pluginConfig = new PluginConfig(this); + pluginConfig.load(); + + databaseManager = new DatabaseManager(this); + databaseManager.start(); + startCleanupTask(); + + containerListener = new ContainerListener(this); + getServer().getPluginManager().registerEvents(containerListener, this); + + AclCommand aclCommand = new AclCommand(this); + PluginCommand command = getCommand("acl"); + if (command != null) { + command.setExecutor(aclCommand); + command.setTabCompleter(aclCommand); + } + Bukkit.getConsoleSender().sendMessage("§f[§6!§f] §aAuroraContainerLog §f加载完成,祝你使用愉快!"); + Bukkit.getConsoleSender().sendMessage("§f[§6!§f] §bBy.极光像素工作室·妖魂"); + } + + @Override + public void onDisable() { + HandlerList.unregisterAll(this); + if (containerListener != null) { + containerListener.clearSessions(); + } + stopCleanupTask(); + if (databaseManager != null) { + databaseManager.stop(); + } + instance = null; + } + + public static CLMain inst() { + return instance; + } + + public PluginConfig getPluginConfig() { + return pluginConfig; + } + + public DatabaseManager getDatabaseManager() { + return databaseManager; + } + + public ContainerListener getContainerListener() { + return containerListener; + } + + public void reloadPlugin() { + reloadConfig(); + pluginConfig.load(); + if (containerListener != null) { + containerListener.clearSessions(); + } + if (databaseManager != null) { + databaseManager.reload(); + } + startCleanupTask(); + } + + private void startCleanupTask() { + stopCleanupTask(); + if (!pluginConfig.isCleanupEnabled()) { + return; + } + + databaseManager.cleanupExpiredRecords(); + long intervalTicks = pluginConfig.getCleanupIntervalHours() * 60L * 60L * 20L; + cleanupTask = getServer().getScheduler().runTaskTimer(this, new Runnable() { + @Override + public void run() { + databaseManager.cleanupExpiredRecords(); + } + }, intervalTicks, intervalTicks); + } + + private void stopCleanupTask() { + if (cleanupTask != null) { + cleanupTask.cancel(); + cleanupTask = null; + } + } +} diff --git a/src/main/java/com/yaohun/containerlog/command/AclCommand.java b/src/main/java/com/yaohun/containerlog/command/AclCommand.java new file mode 100644 index 0000000..615a313 --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/command/AclCommand.java @@ -0,0 +1,283 @@ +package com.yaohun.containerlog.command; + +import com.yaohun.containerlog.CLMain; +import com.yaohun.containerlog.database.QueryCallback; +import com.yaohun.containerlog.model.LogRecord; +import com.yaohun.containerlog.model.PageResult; +import com.yaohun.containerlog.util.ChestLocationResolver; +import com.yaohun.containerlog.util.TimeUtil; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.ComponentBuilder; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class AclCommand implements CommandExecutor, TabCompleter { + + private static final String PERMISSION = "auroracontainerlog.admin"; + + private final CLMain plugin; + + public AclCommand(CLMain plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length >= 1 && "chest".equalsIgnoreCase(args[0])) { + handleChest(sender, args); + return true; + } + if (!sender.hasPermission(PERMISSION)) { + return true; + } + if (args.length == 0) { + sendUsage(sender, label); + return true; + } + String subCommand = args[0].toLowerCase(); + if ("reload".equals(subCommand)) { + handleReload(sender); + return true; + } + if ("player".equals(subCommand)) { + handlePlayer(sender, args); + return true; + } + if ("near".equals(subCommand)) { + handleNear(sender, args); + return true; + } + + sendUsage(sender, label); + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!sender.hasPermission(PERMISSION)) { + return Collections.emptyList(); + } + if (args.length == 1) { + return filter(Arrays.asList("reload", "player", "chest", "near"), args[0]); + } + if (args.length == 2) { + return Bukkit.getOnlinePlayers().stream() + .map(Player::getName) + .filter(name -> name.toLowerCase().startsWith(args[1].toLowerCase())) + .collect(Collectors.toList()); + } + return Collections.emptyList(); + } + + private void handleReload(CommandSender sender) { + plugin.reloadPlugin(); + sender.sendMessage("§f[§c箱子查询§f] §a配置文件已重载."); + } + + private void handlePlayer(final CommandSender sender, String[] args) { + String prefix = "§f[§c玩家查询§f] §a"; + if (args.length < 2) { + sender.sendMessage(prefix+"用法: §e/acl player [玩家名] <天数>"); + return; + } + String playerName = args[1]; + Integer days = 1; + if(args.length >= 3){ + days = parsePositiveInt(args[2]); + if (days == null) { + sender.sendMessage(prefix+"天数必须是整数."); + return; + } + } + int page = parsePage(args, 3); + long since = TimeUtil.daysAgoMillis(days); + sender.sendMessage(prefix+"正在查询 §e"+playerName+" §a最近 §e"+days+"天 §a的记录..."); + plugin.getDatabaseManager().queryByPlayer(playerName, since, page, plugin.getPluginConfig().getPageSize(), new QueryCallback() { + @Override + public void onComplete(PageResult result) { + displayResult(sender, "玩家记录", result); + } + }); + } + + private void handleChest(final CommandSender sender, String[] args) { + if (!(sender instanceof Player)) { + sender.sendMessage("§c该命令只能由玩家执行"); + return; + } + String prefix = "§f[§c箱子查询§f] §a"; + Player player = (Player) sender; + Block block = getTargetBlock(player); + if (!ChestLocationResolver.isChestBlock(block)) { + sender.sendMessage(prefix+"请看着一个箱子后执行该命令."); + return; + } + if (!plugin.getPluginConfig().isWorldEnabled(block.getWorld().getName())) { + sender.sendMessage(prefix+"当前世界无法使用该命令查询."); + return; + } + + int page = parsePage(args, 1); + long since = TimeUtil.daysAgoMillis(plugin.getPluginConfig().getChestRecentDays()); + List locations = ChestLocationResolver.resolveLinkedChestLocations(block); + + sender.sendMessage(prefix+"正在查询该箱子最近的记录..."); + plugin.getDatabaseManager().queryByLocations(locations, since, page, plugin.getPluginConfig().getPageSize(), new QueryCallback() { + @Override + public void onComplete(PageResult result) { + displayResult(sender, "箱子记录", result); + } + }); + } + + private void handleNear(final CommandSender sender, String[] args) { + String prefix = "§f[§c箱子查询§f] §a"; + if (!(sender instanceof Player)) { + sender.sendMessage("§c该命令只能由玩家执行."); + return; + } + if (args.length < 2) { + sender.sendMessage(prefix+"用法: §e/acl near [半径] <天数>"); + return; + } + Integer radius = parsePositiveInt(args[1]); + if (radius == null) { + sender.sendMessage(prefix+"半径必须是正整数."); + return; + } + Integer days = 1; + if(args.length >= 3){ + days = parsePositiveInt(args[2]); + if (days == null) { + sender.sendMessage(prefix+"天数必须是整数."); + return; + } + } + int maxRadius = plugin.getPluginConfig().getMaxNearRadius(); + if (radius > maxRadius) { + sender.sendMessage(prefix+"查询半径不能超过 §e" + maxRadius+"格"); + return; + } + + Player player = (Player) sender; + if (!plugin.getPluginConfig().isWorldEnabled(player.getWorld().getName())) { + sender.sendMessage(prefix+"当前世界无法使用该命令查询."); + return; + } + + int page = parsePage(args, 3); + long since = TimeUtil.daysAgoMillis(days); + sender.sendMessage(prefix+"正在查询附近 §e"+radius+"格 §a最近 §e"+days+"天 §a的记录..."); + plugin.getDatabaseManager().queryNear(player.getLocation(), radius, since, page, plugin.getPluginConfig().getPageSize(), new QueryCallback() { + @Override + public void onComplete(PageResult result) { + displayResult(sender, "附近记录", result); + } + }); + } + + private void displayResult(CommandSender sender, String title, PageResult result) { + sender.sendMessage("§8§m-------------------------------------------------------------"); + sender.sendMessage("§b" + title + " §7共 §f" + result.getTotal() + " §7条,第 §f" + result.getPage() + "§7/§f" + result.getTotalPages() + " §7页"); + if (result.getRecords().isEmpty()) { + sender.sendMessage("§c暂时还没有任何数据产生"); + } else { + if(sender instanceof Player){ + Player player = (Player) sender; + for (LogRecord record : result.getRecords()) { + sendClickMessage(player,formatRecord(record,true),"tp "+record.getX()+" "+record.getY()+" "+record.getZ()); + } + } else { + for (LogRecord record : result.getRecords()) { + sender.sendMessage(formatRecord(record,false)); + } + } + } + sender.sendMessage("§8§m-------------------------------------------------------------"); + } + + private String formatRecord(LogRecord record,boolean hide) { + String actionColor = "PUT".equals(record.getAction().name()) ? "§a" : "§c"; + String itemName = record.getDisplayName() == null || record.getDisplayName().isEmpty() ? record.getMaterial() : record.getDisplayName(); + if(hide){ + return "§7#" + record.getId() + + " §f" + TimeUtil.format(record.getCreatedAt()) + + " " + actionColor + record.getAction().getName() + + " §e" + record.getPlayerName() + + " §7" + itemName + " §fx" + record.getAmount(); + } + return "§7#" + record.getId() + + " §f" + TimeUtil.format(record.getCreatedAt()) + + " " + actionColor + record.getAction().getName() + + " §e" + record.getPlayerName() + + " §7" + itemName + " §fx" + record.getAmount() + + " §8@ §7" + record.getWorld() + "/" + record.getX() + "/" + record.getY() + "/" + record.getZ(); + } + + private void sendUsage(CommandSender sender, String label) { + sender.sendMessage(""); + sender.sendMessage("§e------- ======= §6箱子数据查询 §e======= -------"); + sender.sendMessage("§2/"+label+" chest §e[页码] §f- §2查看箱子数据"); + sender.sendMessage("§2/"+label+" player §e[玩家] §b<天数> §f- §2查询玩家数据"); + sender.sendMessage("§2/"+label+" near §e[半径] §b<天数> §f- §2查询周围箱子"); + sender.sendMessage("§2/"+label+" reload §f- §2重载配置文件"); + sender.sendMessage("§e------- ======= §6箱子数据查询 §e======= -------"); + sender.sendMessage(""); + } + + private int parsePage(String[] args, int index) { + if (args.length <= index) { + return plugin.getPluginConfig().getDefaultPage(); + } + Integer page = parsePositiveInt(args[index]); + return page == null ? plugin.getPluginConfig().getDefaultPage() : page; + } + + private Integer parsePositiveInt(String value) { + try { + int parsed = Integer.parseInt(value); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException exception) { + return null; + } + } + + private Block getTargetBlock(Player player) { + return player.getTargetBlock((Set) null, 8); + } + + private List filter(List source, String prefix) { + List result = new ArrayList(); + String lowerPrefix = prefix == null ? "" : prefix.toLowerCase(); + for (String value : source) { + if (value.startsWith(lowerPrefix)) { + result.add(value); + } + } + return result; + } + + private void sendClickMessage(Player player, String message,String command){ + TextComponent tomessage = new TextComponent(message); + tomessage.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/"+command)); + tomessage.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder("/"+command).create())); + player.spigot().sendMessage(tomessage); + } +} diff --git a/src/main/java/com/yaohun/containerlog/config/PluginConfig.java b/src/main/java/com/yaohun/containerlog/config/PluginConfig.java new file mode 100644 index 0000000..66a116a --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/config/PluginConfig.java @@ -0,0 +1,95 @@ +package com.yaohun.containerlog.config; + +import com.yaohun.containerlog.CLMain; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.configuration.file.FileConfiguration; + +import java.io.File; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class PluginConfig { + + private final CLMain plugin; + private final Set listenWorlds = new HashSet(); + private int pageSize; + private int chestRecentDays; + private int maxNearRadius; + private int defaultPage; + private boolean cleanupEnabled; + private int cleanupRetentionDays; + private int cleanupIntervalHours; + private String databaseFile; + + public PluginConfig(CLMain plugin) { + this.plugin = plugin; + } + + public void load() { + plugin.saveDefaultConfig(); + FileConfiguration config = plugin.getConfig(); + + listenWorlds.clear(); + List worlds = config.getStringList("listen-worlds"); + for (String world : worlds) { + if (world != null && !world.trim().isEmpty()) { + listenWorlds.add(world.trim().toLowerCase(Locale.ENGLISH)); + } + } + Bukkit.getConsoleSender().sendMessage("§f[§a!§f] §f监听世界 §8> §6" + listenWorlds.size() + "个"); + pageSize = Math.max(1, config.getInt("query.page-size", 8)); + chestRecentDays = Math.max(1, config.getInt("query.chest-recent-days", 30)); + maxNearRadius = Math.max(1, config.getInt("query.max-near-radius", 50)); + defaultPage = Math.max(1, config.getInt("query.default-page", 1)); + databaseFile = config.getString("database.file", "containerlog.db"); + cleanupEnabled = config.getBoolean("cleanup.enabled", true); + cleanupRetentionDays = Math.max(1, config.getInt("cleanup.retention-days", 60)); + if (cleanupEnabled){ + Bukkit.getConsoleSender().sendMessage("§f[§a!§f] §f数据缓存 §8> §6" + cleanupRetentionDays + "天"); + } + cleanupIntervalHours = Math.max(1, config.getInt("cleanup.interval-hours", 24)); + } + + public boolean isWorldEnabled(String worldName) { + return worldName != null && listenWorlds.contains(worldName.toLowerCase(Locale.ENGLISH)); + } + + public int getPageSize() { + return pageSize; + } + + public int getChestRecentDays() { + return chestRecentDays; + } + + public int getMaxNearRadius() { + return maxNearRadius; + } + + public int getDefaultPage() { + return defaultPage; + } + + public boolean isCleanupEnabled() { + return cleanupEnabled; + } + + public int getCleanupRetentionDays() { + return cleanupRetentionDays; + } + + public int getCleanupIntervalHours() { + return cleanupIntervalHours; + } + + public File getDatabaseFile() { + return new File(plugin.getDataFolder(), databaseFile); + } + + public String color(String message) { + return ChatColor.translateAlternateColorCodes('&', message == null ? "" : message); + } +} diff --git a/src/main/java/com/yaohun/containerlog/database/DatabaseManager.java b/src/main/java/com/yaohun/containerlog/database/DatabaseManager.java new file mode 100644 index 0000000..63fce3d --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/database/DatabaseManager.java @@ -0,0 +1,436 @@ +package com.yaohun.containerlog.database; + +import com.yaohun.containerlog.CLMain; +import com.yaohun.containerlog.model.ContainerAction; +import com.yaohun.containerlog.model.LogRecord; +import com.yaohun.containerlog.model.PageResult; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.bukkit.Bukkit; +import org.bukkit.Location; + +import java.io.File; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +public class DatabaseManager { + + private final CLMain plugin; + private ExecutorService executorService; + private HikariDataSource dataSource; + private File databaseFile; + + public DatabaseManager(CLMain plugin) { + this.plugin = plugin; + } + + public synchronized void start() { + databaseFile = plugin.getPluginConfig().getDatabaseFile(); + File parent = databaseFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] 无法创建数据库目录:" + parent.getAbsolutePath()); + } + + try { + dataSource = createSQLiteDataSource(databaseFile, "AuroraContainerLog-SQLite"); + } catch (RuntimeException exception) { + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] 当前环境未检测到 sqlite-jdbc 驱动"); + return; + } + + executorService = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "AuroraContainerLog-SQLite"); + thread.setDaemon(true); + return thread; + } + }); + executorService.execute(new Runnable() { + @Override + public void run() { + initializeSchema(); + } + }); + } + + public synchronized void reload() { + stop(); + start(); + } + + public synchronized void stop() { + if (executorService != null) { + ExecutorService executor = executorService; + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException exception) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + executorService = null; + } + closeDataSource(); + } + + public void insertRecords(final List records) { + if (records == null || records.isEmpty()) { + return; + } + ExecutorService executor = executorService; + if (executor == null) { + return; + } + executor.execute(new Runnable() { + @Override + public void run() { + insertRecordsInternal(records); + } + }); + } + + public void cleanupExpiredRecords() { + ExecutorService executor = executorService; + if (executor == null) { + return; + } + final int retentionDays = plugin.getPluginConfig().getCleanupRetentionDays(); + executor.execute(new Runnable() { + @Override + public void run() { + cleanupExpiredRecordsInternal(retentionDays); + } + }); + } + + public void queryByPlayer(String playerName, long sinceMillis, int page, int pageSize, QueryCallback callback) { + String where = "lower(player_name) = lower(?) AND created_at >= ?"; + List params = new ArrayList(); + params.add(playerName); + params.add(sinceMillis); + query(where, params, page, pageSize, callback); + } + + public void queryByLocations(List locations, long sinceMillis, int page, int pageSize, QueryCallback callback) { + if (locations == null || locations.isEmpty()) { + complete(callback, new PageResult(new ArrayList(), Math.max(1, page), 1, 0)); + return; + } + + StringBuilder where = new StringBuilder("("); + List params = new ArrayList(); + for (int index = 0; index < locations.size(); index++) { + if (index > 0) { + where.append(" OR "); + } + where.append("(world = ? AND x = ? AND y = ? AND z = ?)"); + Location location = locations.get(index); + params.add(location.getWorld().getName()); + params.add(location.getBlockX()); + params.add(location.getBlockY()); + params.add(location.getBlockZ()); + } + where.append(") AND created_at >= ?"); + params.add(sinceMillis); + query(where.toString(), params, page, pageSize, callback); + } + + public void queryNear(Location center, int radius, long sinceMillis, int page, int pageSize, QueryCallback callback) { + String where = "world = ? AND x BETWEEN ? AND ? AND y BETWEEN ? AND ? AND z BETWEEN ? AND ? AND created_at >= ?"; + List params = new ArrayList(); + params.add(center.getWorld().getName()); + params.add(center.getBlockX() - radius); + params.add(center.getBlockX() + radius); + params.add(center.getBlockY() - radius); + params.add(center.getBlockY() + radius); + params.add(center.getBlockZ() - radius); + params.add(center.getBlockZ() + radius); + params.add(sinceMillis); + query(where, params, page, pageSize, callback); + } + + private HikariDataSource createSQLiteDataSource(File file, String poolName) { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:sqlite:" + file.getAbsolutePath()); + config.setPoolName(poolName); + config.setMaximumPoolSize(1); + config.setMinimumIdle(1); + config.setConnectionTimeout(10000L); + config.setValidationTimeout(3000L); + config.setIdleTimeout(600000L); + config.setMaxLifetime(1700000L); + config.setAutoCommit(true); + return new HikariDataSource(config); + } + + private void query(final String where, final List params, final int page, final int pageSize, final QueryCallback callback) { + ExecutorService executor = executorService; + if (executor == null) { + complete(callback, new PageResult(new ArrayList(), Math.max(1, page), 1, 0)); + return; + } + executor.execute(new Runnable() { + @Override + public void run() { + PageResult result; + try { + result = queryInternal(where, params, page, pageSize); + } catch (SQLException exception) { + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] 查询容器日志失败"); + result = new PageResult(new ArrayList(), Math.max(1, page), 1, 0); + } + complete(callback, result); + } + }); + } + + private PageResult queryInternal(String where, List params, int page, int pageSize) throws SQLException { + int currentPage = Math.max(1, page); + int size = Math.max(1, pageSize); + int total; + + Connection connection = getConnection(); + try { + PreparedStatement countStatement = connection.prepareStatement("SELECT COUNT(*) FROM acl_records WHERE " + where); + try { + bindParams(countStatement, params); + ResultSet countResult = countStatement.executeQuery(); + try { + total = countResult.next() ? countResult.getInt(1) : 0; + } finally { + countResult.close(); + } + } finally { + countStatement.close(); + } + + int totalPages = Math.max(1, (int) Math.ceil(total / (double) size)); + if (currentPage > totalPages) { + currentPage = totalPages; + } + + PreparedStatement queryStatement = connection.prepareStatement("SELECT * FROM acl_records WHERE " + where + " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"); + try { + bindParams(queryStatement, params); + queryStatement.setInt(params.size() + 1, size); + queryStatement.setInt(params.size() + 2, (currentPage - 1) * size); + ResultSet resultSet = queryStatement.executeQuery(); + try { + List records = new ArrayList(); + while (resultSet.next()) { + records.add(readRecord(resultSet)); + } + return new PageResult(records, currentPage, totalPages, total); + } finally { + resultSet.close(); + } + } finally { + queryStatement.close(); + } + } finally { + connection.close(); + } + } + + private void insertRecordsInternal(List records) { + Connection connection = null; + try { + connection = getConnection(); + connection.setAutoCommit(false); + PreparedStatement statement = connection.prepareStatement("INSERT INTO acl_records (created_at, world, x, y, z, player_name, player_uuid, action, material, amount, display_name, lore, enchantments, item_stack_base64) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + try { + for (LogRecord record : records) { + statement.setLong(1, record.getCreatedAt()); + statement.setString(2, record.getWorld()); + statement.setInt(3, record.getX()); + statement.setInt(4, record.getY()); + statement.setInt(5, record.getZ()); + statement.setString(6, record.getPlayerName()); + statement.setString(7, record.getPlayerUuid()); + statement.setString(8, record.getAction().name()); + statement.setString(9, record.getMaterial()); + statement.setInt(10, record.getAmount()); + statement.setString(11, record.getDisplayName()); + statement.setString(12, record.getLore()); + statement.setString(13, record.getEnchantments()); + statement.setString(14, record.getItemStackBase64()); + statement.addBatch(); + } + statement.executeBatch(); + } finally { + statement.close(); + } + connection.commit(); + } catch (SQLException exception) { + rollback(connection); + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] 写入容器日志失败"); + } finally { + close(connection); + } + } + + private void cleanupExpiredRecordsInternal(int retentionDays) { + long expireBefore = System.currentTimeMillis() - (Math.max(1, retentionDays) * 86400000L); + Connection connection = null; + try { + connection = getConnection(); + PreparedStatement statement = connection.prepareStatement("DELETE FROM acl_records WHERE created_at < ?"); + try { + statement.setLong(1, expireBefore); + int deleted = statement.executeUpdate(); + if (deleted > 0) { + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] 已清理 "+deleted+" 条超过 "+retentionDays+" 天的容器缓存。"); + } + } finally { + statement.close(); + } + } catch (SQLException exception) { + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] 清理过期容器日志失败"); + } finally { + close(connection); + } + } + + private void initializeSchema() { + Connection connection = null; + try { + connection = getConnection(); + Statement statement = connection.createStatement(); + try { + statement.executeUpdate("CREATE TABLE IF NOT EXISTS acl_records (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "created_at INTEGER NOT NULL," + + "world TEXT NOT NULL," + + "x INTEGER NOT NULL," + + "y INTEGER NOT NULL," + + "z INTEGER NOT NULL," + + "player_name TEXT NOT NULL," + + "player_uuid TEXT NOT NULL," + + "action TEXT NOT NULL," + + "material TEXT NOT NULL," + + "amount INTEGER NOT NULL," + + "display_name TEXT," + + "lore TEXT," + + "enchantments TEXT," + + "item_stack_base64 TEXT NOT NULL" + + ")"); + statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_acl_records_player_time ON acl_records(player_name, created_at)"); + statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_acl_records_location_time ON acl_records(world, x, y, z, created_at)"); + statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_acl_records_time ON acl_records(created_at)"); + } finally { + statement.close(); + } + } catch (SQLException exception) { + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] SQLite 初始化失败"); + } finally { + close(connection); + } + } + + private Connection getConnection() throws SQLException { + HikariDataSource currentDataSource = dataSource; + if (currentDataSource == null || currentDataSource.isClosed()) { + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] SQLite 连接池未初始化"); + throw new SQLException(); + } + + Connection connection = currentDataSource.getConnection(); + Statement statement = connection.createStatement(); + try { + statement.execute("PRAGMA busy_timeout = 5000"); + statement.execute("PRAGMA journal_mode = WAL"); + } finally { + statement.close(); + } + return connection; + } + + private void bindParams(PreparedStatement statement, List params) throws SQLException { + for (int index = 0; index < params.size(); index++) { + Object value = params.get(index); + if (value instanceof String) { + statement.setString(index + 1, (String) value); + } else if (value instanceof Integer) { + statement.setInt(index + 1, (Integer) value); + } else if (value instanceof Long) { + statement.setLong(index + 1, (Long) value); + } else { + statement.setObject(index + 1, value); + } + } + } + + private LogRecord readRecord(ResultSet resultSet) throws SQLException { + LogRecord record = new LogRecord(); + record.setId(resultSet.getLong("id")); + record.setCreatedAt(resultSet.getLong("created_at")); + record.setWorld(resultSet.getString("world")); + record.setX(resultSet.getInt("x")); + record.setY(resultSet.getInt("y")); + record.setZ(resultSet.getInt("z")); + record.setPlayerName(resultSet.getString("player_name")); + record.setPlayerUuid(resultSet.getString("player_uuid")); + record.setAction(ContainerAction.valueOf(resultSet.getString("action"))); + record.setMaterial(resultSet.getString("material")); + record.setAmount(resultSet.getInt("amount")); + record.setDisplayName(resultSet.getString("display_name")); + record.setLore(resultSet.getString("lore")); + record.setEnchantments(resultSet.getString("enchantments")); + record.setItemStackBase64(resultSet.getString("item_stack_base64")); + return record; + } + + private void complete(final QueryCallback callback, final PageResult result) { + if (callback == null) { + return; + } + Bukkit.getScheduler().runTask(plugin, new Runnable() { + @Override + public void run() { + callback.onComplete(result); + } + }); + } + + private void rollback(Connection connection) { + if (connection == null) { + return; + } + try { + connection.rollback(); + } catch (SQLException exception) { + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] 回滚容器日志事务失败"); + } + } + + private void close(Connection connection) { + if (connection == null) { + return; + } + try { + connection.close(); + } catch (SQLException exception) { + Bukkit.getConsoleSender().sendMessage("[日志 - 容器数据] 关闭 SQLite 连接失败"); + } + } + + private void closeDataSource() { + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + } + dataSource = null; + } +} diff --git a/src/main/java/com/yaohun/containerlog/database/QueryCallback.java b/src/main/java/com/yaohun/containerlog/database/QueryCallback.java new file mode 100644 index 0000000..c042be9 --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/database/QueryCallback.java @@ -0,0 +1,8 @@ +package com.yaohun.containerlog.database; + +import com.yaohun.containerlog.model.PageResult; + +public interface QueryCallback { + + void onComplete(PageResult result); +} diff --git a/src/main/java/com/yaohun/containerlog/listener/ContainerListener.java b/src/main/java/com/yaohun/containerlog/listener/ContainerListener.java new file mode 100644 index 0000000..23c0a83 --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/listener/ContainerListener.java @@ -0,0 +1,187 @@ +package com.yaohun.containerlog.listener; + +import com.yaohun.containerlog.CLMain; +import com.yaohun.containerlog.model.ContainerSession; +import com.yaohun.containerlog.model.ItemChange; +import com.yaohun.containerlog.model.LogRecord; +import com.yaohun.containerlog.util.ChestLocationResolver; +import com.yaohun.containerlog.util.ItemDiffUtil; +import com.yaohun.containerlog.util.ItemStackUtil; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; + +public class ContainerListener implements Listener { + + private static final long CLICKED_LOCATION_EXPIRE_MILLIS = 2500L; + + private final CLMain plugin; + private final Map sessions = new HashMap(); + private final Map clickedChests = new HashMap(); + + public ContainerListener(CLMain plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerInteract(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK) { + return; + } + + Block block = event.getClickedBlock(); + if (!ChestLocationResolver.isChestBlock(block)) { + return; + } + if (!plugin.getPluginConfig().isWorldEnabled(block.getWorld().getName())) { + return; + } + + clickedChests.put(event.getPlayer().getUniqueId(), new ClickedChest(block.getLocation(), System.currentTimeMillis())); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onInventoryOpen(InventoryOpenEvent event) { + if (!(event.getPlayer() instanceof Player)) { + return; + } + + Player player = (Player) event.getPlayer(); + Inventory topInventory = event.getView().getTopInventory(); + if (!ChestLocationResolver.isChestInventory(topInventory)) { + return; + } + + Location clickedLocation = consumeClickedLocation(player.getUniqueId()); + Location containerLocation = ChestLocationResolver.resolvePrimaryLocation(topInventory, clickedLocation); + if (containerLocation == null || containerLocation.getWorld() == null) { + return; + } + if (!plugin.getPluginConfig().isWorldEnabled(containerLocation.getWorld().getName())) { + return; + } + + ItemStack[] snapshot = ItemDiffUtil.cloneContents(topInventory.getContents()); + sessions.put(player.getUniqueId(), new ContainerSession(player.getUniqueId(), player.getName(), containerLocation, snapshot)); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryClick(InventoryClickEvent event) { + if (event.getWhoClicked() instanceof Player) { + markTouched((Player) event.getWhoClicked()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryDrag(InventoryDragEvent event) { + if (event.getWhoClicked() instanceof Player) { + markTouched((Player) event.getWhoClicked()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onInventoryClose(InventoryCloseEvent event) { + if (!(event.getPlayer() instanceof Player)) { + return; + } + + Player player = (Player) event.getPlayer(); + ContainerSession session = sessions.remove(player.getUniqueId()); + if (session == null || !session.isTouched()) { + return; + } + + Inventory topInventory = event.getView().getTopInventory(); + if (!ChestLocationResolver.isChestInventory(topInventory)) { + return; + } + + try { + List changes = ItemDiffUtil.diff(session.getSnapshot(), topInventory.getContents()); + if (changes.isEmpty()) { + return; + } + plugin.getDatabaseManager().insertRecords(toRecords(session, changes)); + } catch (IOException exception) { + plugin.getLogger().log(Level.SEVERE, "序列化容器物品变化失败", exception); + } + } + + public void clearSessions() { + sessions.clear(); + clickedChests.clear(); + } + + private void markTouched(Player player) { + ContainerSession session = sessions.get(player.getUniqueId()); + if (session != null) { + session.markTouched(); + } + } + + private Location consumeClickedLocation(UUID playerUuid) { + ClickedChest clickedChest = clickedChests.remove(playerUuid); + if (clickedChest == null) { + return null; + } + if (System.currentTimeMillis() - clickedChest.clickedAt > CLICKED_LOCATION_EXPIRE_MILLIS) { + return null; + } + return clickedChest.location; + } + + private List toRecords(ContainerSession session, List changes) throws IOException { + List records = new ArrayList(); + Location location = session.getContainerLocation(); + long now = System.currentTimeMillis(); + for (ItemChange change : changes) { + ItemStack itemStack = change.getItemStack(); + LogRecord record = new LogRecord(); + record.setCreatedAt(now); + record.setWorld(location.getWorld().getName()); + record.setX(location.getBlockX()); + record.setY(location.getBlockY()); + record.setZ(location.getBlockZ()); + record.setPlayerName(session.getPlayerName()); + record.setPlayerUuid(session.getPlayerUuid().toString()); + record.setAction(change.getAction()); + record.setMaterial(itemStack.getType().name()); + record.setAmount(change.getAmount()); + record.setDisplayName(ItemStackUtil.getDisplayName(itemStack)); + record.setLore(ItemStackUtil.getLore(itemStack)); + record.setEnchantments(ItemStackUtil.getEnchantments(itemStack)); + record.setItemStackBase64(ItemStackUtil.toBase64(itemStack)); + records.add(record); + } + return records; + } + + private static class ClickedChest { + private final Location location; + private final long clickedAt; + + private ClickedChest(Location location, long clickedAt) { + this.location = location; + this.clickedAt = clickedAt; + } + } +} diff --git a/src/main/java/com/yaohun/containerlog/model/ContainerAction.java b/src/main/java/com/yaohun/containerlog/model/ContainerAction.java new file mode 100644 index 0000000..fc651df --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/model/ContainerAction.java @@ -0,0 +1,16 @@ +package com.yaohun.containerlog.model; + +public enum ContainerAction { + TAKE("取出"), + PUT("放入"); + + private final String name; + + ContainerAction(String name){ + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/yaohun/containerlog/model/ContainerSession.java b/src/main/java/com/yaohun/containerlog/model/ContainerSession.java new file mode 100644 index 0000000..cf58e3a --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/model/ContainerSession.java @@ -0,0 +1,52 @@ +package com.yaohun.containerlog.model; + +import org.bukkit.Location; +import org.bukkit.inventory.ItemStack; + +import java.util.UUID; + +public class ContainerSession { + + private final UUID playerUuid; + private final String playerName; + private final Location containerLocation; + private final ItemStack[] snapshot; + private final long openedAt; + private boolean touched; + + public ContainerSession(UUID playerUuid, String playerName, Location containerLocation, ItemStack[] snapshot) { + this.playerUuid = playerUuid; + this.playerName = playerName; + this.containerLocation = containerLocation; + this.snapshot = snapshot; + this.openedAt = System.currentTimeMillis(); + } + + public UUID getPlayerUuid() { + return playerUuid; + } + + public String getPlayerName() { + return playerName; + } + + public Location getContainerLocation() { + return containerLocation; + } + + public ItemStack[] getSnapshot() { + return snapshot; + } + + public long getOpenedAt() { + return openedAt; + } + + public boolean isTouched() { + return touched; + } + + public void markTouched() { + touched = true; + } +} diff --git a/src/main/java/com/yaohun/containerlog/model/ItemChange.java b/src/main/java/com/yaohun/containerlog/model/ItemChange.java new file mode 100644 index 0000000..1e5c660 --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/model/ItemChange.java @@ -0,0 +1,28 @@ +package com.yaohun.containerlog.model; + +import org.bukkit.inventory.ItemStack; + +public class ItemChange { + + private final ContainerAction action; + private final ItemStack itemStack; + private final int amount; + + public ItemChange(ContainerAction action, ItemStack itemStack, int amount) { + this.action = action; + this.itemStack = itemStack; + this.amount = amount; + } + + public ContainerAction getAction() { + return action; + } + + public ItemStack getItemStack() { + return itemStack; + } + + public int getAmount() { + return amount; + } +} diff --git a/src/main/java/com/yaohun/containerlog/model/LogRecord.java b/src/main/java/com/yaohun/containerlog/model/LogRecord.java new file mode 100644 index 0000000..7bd941a --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/model/LogRecord.java @@ -0,0 +1,140 @@ +package com.yaohun.containerlog.model; + +public class LogRecord { + + private long id; + private long createdAt; + private String world; + private int x; + private int y; + private int z; + private String playerName; + private String playerUuid; + private ContainerAction action; + private String material; + private int amount; + private String displayName; + private String lore; + private String enchantments; + private String itemStackBase64; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getWorld() { + return world; + } + + public void setWorld(String world) { + this.world = world; + } + + public int getX() { + return x; + } + + public void setX(int x) { + this.x = x; + } + + public int getY() { + return y; + } + + public void setY(int y) { + this.y = y; + } + + public int getZ() { + return z; + } + + public void setZ(int z) { + this.z = z; + } + + public String getPlayerName() { + return playerName; + } + + public void setPlayerName(String playerName) { + this.playerName = playerName; + } + + public String getPlayerUuid() { + return playerUuid; + } + + public void setPlayerUuid(String playerUuid) { + this.playerUuid = playerUuid; + } + + public ContainerAction getAction() { + return action; + } + + public void setAction(ContainerAction action) { + this.action = action; + } + + public String getMaterial() { + return material; + } + + public void setMaterial(String material) { + this.material = material; + } + + public int getAmount() { + return amount; + } + + public void setAmount(int amount) { + this.amount = amount; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getLore() { + return lore; + } + + public void setLore(String lore) { + this.lore = lore; + } + + public String getEnchantments() { + return enchantments; + } + + public void setEnchantments(String enchantments) { + this.enchantments = enchantments; + } + + public String getItemStackBase64() { + return itemStackBase64; + } + + public void setItemStackBase64(String itemStackBase64) { + this.itemStackBase64 = itemStackBase64; + } +} diff --git a/src/main/java/com/yaohun/containerlog/model/PageResult.java b/src/main/java/com/yaohun/containerlog/model/PageResult.java new file mode 100644 index 0000000..2c7182b --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/model/PageResult.java @@ -0,0 +1,35 @@ +package com.yaohun.containerlog.model; + +import java.util.Collections; +import java.util.List; + +public class PageResult { + + private final List records; + private final int page; + private final int totalPages; + private final int total; + + public PageResult(List records, int page, int totalPages, int total) { + this.records = records == null ? Collections.emptyList() : records; + this.page = page; + this.totalPages = totalPages; + this.total = total; + } + + public List getRecords() { + return records; + } + + public int getPage() { + return page; + } + + public int getTotalPages() { + return totalPages; + } + + public int getTotal() { + return total; + } +} diff --git a/src/main/java/com/yaohun/containerlog/util/ChestLocationResolver.java b/src/main/java/com/yaohun/containerlog/util/ChestLocationResolver.java new file mode 100644 index 0000000..c3be1e0 --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/util/ChestLocationResolver.java @@ -0,0 +1,113 @@ +package com.yaohun.containerlog.util; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.Chest; +import org.bukkit.block.DoubleChest; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.DoubleChestInventory; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ChestLocationResolver { + + private static final BlockFace[] HORIZONTAL_FACES = new BlockFace[]{ + BlockFace.NORTH, + BlockFace.SOUTH, + BlockFace.EAST, + BlockFace.WEST + }; + + private ChestLocationResolver() { + } + + public static boolean isChestInventory(Inventory inventory) { + if (inventory == null) { + return false; + } + InventoryHolder holder = inventory.getHolder(); + if (holder instanceof Chest || holder instanceof DoubleChest || inventory instanceof DoubleChestInventory) { + return true; + } + return inventory.getType() == InventoryType.CHEST && (inventory.getSize() == 27 || inventory.getSize() == 54); + } + + public static boolean isChestBlock(Block block) { + if (block == null) { + return false; + } + String typeName = block.getType().name(); + return "CHEST".equals(typeName) || "TRAPPED_CHEST".equals(typeName); + } + + public static Location resolvePrimaryLocation(Inventory inventory, Location clickedLocation) { + if (clickedLocation != null && isChestBlock(clickedLocation.getBlock())) { + return toBlockLocation(clickedLocation); + } + + InventoryHolder holder = inventory == null ? null : inventory.getHolder(); + if (holder instanceof Chest) { + return ((Chest) holder).getBlock().getLocation(); + } + if (holder instanceof DoubleChest) { + DoubleChest doubleChest = (DoubleChest) holder; + Location left = resolveHolderLocation(doubleChest.getLeftSide()); + if (left != null) { + return left; + } + return resolveHolderLocation(doubleChest.getRightSide()); + } + if (inventory instanceof DoubleChestInventory) { + DoubleChestInventory doubleChestInventory = (DoubleChestInventory) inventory; + Location left = resolveInventoryLocation(doubleChestInventory.getLeftSide()); + if (left != null) { + return left; + } + return resolveInventoryLocation(doubleChestInventory.getRightSide()); + } + return null; + } + + public static List resolveLinkedChestLocations(Block block) { + Map locations = new LinkedHashMap(); + if (!isChestBlock(block)) { + return new ArrayList(); + } + + addLocation(locations, block.getLocation()); + for (BlockFace face : HORIZONTAL_FACES) { + Block relative = block.getRelative(face); + if (isChestBlock(relative) && relative.getType() == block.getType()) { + addLocation(locations, relative.getLocation()); + } + } + return new ArrayList(locations.values()); + } + + private static Location resolveHolderLocation(InventoryHolder holder) { + if (holder instanceof Chest) { + return ((Chest) holder).getBlock().getLocation(); + } + return null; + } + + private static Location resolveInventoryLocation(Inventory inventory) { + return inventory == null ? null : resolveHolderLocation(inventory.getHolder()); + } + + private static Location toBlockLocation(Location location) { + return new Location(location.getWorld(), location.getBlockX(), location.getBlockY(), location.getBlockZ()); + } + + private static void addLocation(Map locations, Location location) { + String key = location.getWorld().getName() + ":" + location.getBlockX() + ":" + location.getBlockY() + ":" + location.getBlockZ(); + locations.put(key, toBlockLocation(location)); + } +} diff --git a/src/main/java/com/yaohun/containerlog/util/ItemDiffUtil.java b/src/main/java/com/yaohun/containerlog/util/ItemDiffUtil.java new file mode 100644 index 0000000..91ab7c8 --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/util/ItemDiffUtil.java @@ -0,0 +1,84 @@ +package com.yaohun.containerlog.util; + +import com.yaohun.containerlog.model.ContainerAction; +import com.yaohun.containerlog.model.ItemChange; +import org.bukkit.inventory.ItemStack; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class ItemDiffUtil { + + private ItemDiffUtil() { + } + + public static List diff(ItemStack[] before, ItemStack[] after) throws IOException { + Map beforeMap = count(before); + Map afterMap = count(after); + Set keys = new HashSet(); + keys.addAll(beforeMap.keySet()); + keys.addAll(afterMap.keySet()); + + List changes = new ArrayList(); + for (String key : keys) { + StackCounter beforeCounter = beforeMap.get(key); + StackCounter afterCounter = afterMap.get(key); + int beforeAmount = beforeCounter == null ? 0 : beforeCounter.amount; + int afterAmount = afterCounter == null ? 0 : afterCounter.amount; + int delta = afterAmount - beforeAmount; + if (delta == 0) { + continue; + } + + ContainerAction action = delta > 0 ? ContainerAction.PUT : ContainerAction.TAKE; + int amount = Math.abs(delta); + ItemStack sample = delta > 0 ? afterCounter.sample : beforeCounter.sample; + changes.add(new ItemChange(action, ItemStackUtil.cloneWithAmount(sample, amount), amount)); + } + return changes; + } + + public static ItemStack[] cloneContents(ItemStack[] contents) { + ItemStack[] clone = new ItemStack[contents.length]; + for (int index = 0; index < contents.length; index++) { + ItemStack itemStack = contents[index]; + clone[index] = itemStack == null ? null : itemStack.clone(); + } + return clone; + } + + private static Map count(ItemStack[] contents) throws IOException { + Map counters = new HashMap(); + if (contents == null) { + return counters; + } + + for (ItemStack itemStack : contents) { + if (ItemStackUtil.isEmpty(itemStack)) { + continue; + } + String key = ItemStackUtil.toComparableKey(itemStack); + StackCounter counter = counters.get(key); + if (counter == null) { + counter = new StackCounter(itemStack.clone()); + counters.put(key, counter); + } + counter.amount += itemStack.getAmount(); + } + return counters; + } + + private static class StackCounter { + private final ItemStack sample; + private int amount; + + private StackCounter(ItemStack sample) { + this.sample = sample; + } + } +} diff --git a/src/main/java/com/yaohun/containerlog/util/ItemStackUtil.java b/src/main/java/com/yaohun/containerlog/util/ItemStackUtil.java new file mode 100644 index 0000000..3674f46 --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/util/ItemStackUtil.java @@ -0,0 +1,96 @@ +package com.yaohun.containerlog.util; + +import org.bukkit.Material; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.util.io.BukkitObjectOutputStream; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +public final class ItemStackUtil { + + private ItemStackUtil() { + } + + public static boolean isEmpty(ItemStack itemStack) { + return itemStack == null || itemStack.getType() == Material.AIR || itemStack.getAmount() <= 0; + } + + public static ItemStack cloneWithAmount(ItemStack itemStack, int amount) { + ItemStack clone = itemStack.clone(); + clone.setAmount(amount); + return clone; + } + + public static String toComparableKey(ItemStack itemStack) throws IOException { + return toBase64(cloneWithAmount(itemStack, 1)); + } + + public static String toBase64(ItemStack itemStack) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + BukkitObjectOutputStream dataOutput = new BukkitObjectOutputStream(outputStream); + try { + dataOutput.writeObject(itemStack); + } finally { + dataOutput.close(); + } + return Base64.getEncoder().encodeToString(outputStream.toByteArray()); + } + + public static String getDisplayName(ItemStack itemStack) { + if (isEmpty(itemStack) || !itemStack.hasItemMeta()) { + return null; + } + ItemMeta meta = itemStack.getItemMeta(); + return meta != null && meta.hasDisplayName() ? meta.getDisplayName() : null; + } + + public static String getLore(ItemStack itemStack) { + if (isEmpty(itemStack) || !itemStack.hasItemMeta()) { + return null; + } + ItemMeta meta = itemStack.getItemMeta(); + if (meta == null || !meta.hasLore() || meta.getLore() == null) { + return null; + } + return joinLines(meta.getLore()); + } + + public static String getEnchantments(ItemStack itemStack) { + if (isEmpty(itemStack) || itemStack.getEnchantments().isEmpty()) { + return null; + } + List> entries = new ArrayList>(itemStack.getEnchantments().entrySet()); + Collections.sort(entries, new Comparator>() { + @Override + public int compare(Map.Entry first, Map.Entry second) { + return first.getKey().getName().compareTo(second.getKey().getName()); + } + }); + + List parts = new ArrayList(); + for (Map.Entry entry : entries) { + parts.add(entry.getKey().getName() + ":" + entry.getValue()); + } + return joinLines(parts); + } + + private static String joinLines(List lines) { + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < lines.size(); index++) { + if (index > 0) { + builder.append('\n'); + } + builder.append(lines.get(index)); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/yaohun/containerlog/util/TimeUtil.java b/src/main/java/com/yaohun/containerlog/util/TimeUtil.java new file mode 100644 index 0000000..aa2edd9 --- /dev/null +++ b/src/main/java/com/yaohun/containerlog/util/TimeUtil.java @@ -0,0 +1,25 @@ +package com.yaohun.containerlog.util; + +import java.text.SimpleDateFormat; +import java.util.Date; + +public final class TimeUtil { + + private static final ThreadLocal FORMAT = new ThreadLocal() { + @Override + protected SimpleDateFormat initialValue() { + return new SimpleDateFormat("MM-dd HH:mm"); + } + }; + + private TimeUtil() { + } + + public static long daysAgoMillis(int days) { + return System.currentTimeMillis() - (Math.max(1, days) * 86400000L); + } + + public static String format(long millis) { + return FORMAT.get().format(new Date(millis)); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..7a499ea --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,51 @@ +# ========================================== +# AuroraContainerLog 配置文件 +# ========================================== + +# 监听的世界列表 +# 只有这些世界中的容器操作才会被记录 +# 示例: +# - world +# - world_nether +# - resource +listen-worlds: + - world + +# 查询功能相关配置 +query: + + # 每页显示多少条记录 + # 用于 GUI 或命令查询分页 + page-size: 8 + + # 查询箱子历史时默认显示最近多少天的数据 + # 超过此时间范围的数据不会被展示 + chest-recent-days: 30 + + # 附近容器查询最大允许半径 + # 防止一次查询过大范围导致性能问题 + max-near-radius: 50 + + # 默认打开的页码 + default-page: 1 + +# 数据库配置 +database: + + # SQLite数据库文件名称 + # 文件将保存在插件数据目录下 + file: containerlog.db + +# 自动清理配置 +cleanup: + + # 是否启用自动清理历史记录 + enabled: true + + # 数据保留天数 + # 超过该天数的记录将被删除 + retention-days: 60 + + # 自动清理执行间隔(小时) + # 例如24表示每天执行一次 + interval-hours: 24 \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..9dcc93b --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,16 @@ +name: AuroraContainerLog +main: com.yaohun.containerlog.CLMain +version: 1.1.5 +author: yaohun +description: 记录指定世界箱子容器的物品放入和取出日志 +commands: + acl: + description: AuroraContainerLog 管理命令 + usage: /acl + permission: auroracontainerlog.admin + aliases: + - aclog +permissions: + auroracontainerlog.admin: + description: 允许查询和管理箱子日志 + default: op