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