初始化项目

This commit is contained in:
yhy
2026-06-05 03:56:17 +08:00
commit 9efd1cb851
20 changed files with 2071 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
target/
out/
lib/
libs/
.vscode/
.codex/
.idea/
*.iml
*.class
*.log
logs/
*.db
*.sqlite
*.sqlite3
.DS_Store
Thumbs.db

222
README.md Normal file
View File

@@ -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、数字键、拖拽、大箱子和未监听世界场景。

58
pom.xml Normal file
View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.yaohun</groupId>
<artifactId>AuroraContainerLog</artifactId>
<version>1.1.5</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>public-rpg</id>
<url>https://repo.aurora-pixels.com/repository/public-rpg/</url>
</repository>
<repository>
<id>spigot-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>1.12.2-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>4.0.3</version>
</dependency>
</dependencies>
<build>
<finalName>AuroraContainerLog-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>8</source>
<target>8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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;
}
}
}

View File

@@ -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<String> 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<Location> 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<Material>) null, 8);
}
private List<String> filter(List<String> source, String prefix) {
List<String> result = new ArrayList<String>();
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);
}
}

View File

@@ -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<String> listenWorlds = new HashSet<String>();
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<String> 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);
}
}

View File

@@ -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<LogRecord> 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<Object> params = new ArrayList<Object>();
params.add(playerName);
params.add(sinceMillis);
query(where, params, page, pageSize, callback);
}
public void queryByLocations(List<Location> locations, long sinceMillis, int page, int pageSize, QueryCallback callback) {
if (locations == null || locations.isEmpty()) {
complete(callback, new PageResult(new ArrayList<LogRecord>(), Math.max(1, page), 1, 0));
return;
}
StringBuilder where = new StringBuilder("(");
List<Object> params = new ArrayList<Object>();
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<Object> params = new ArrayList<Object>();
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<Object> params, final int page, final int pageSize, final QueryCallback callback) {
ExecutorService executor = executorService;
if (executor == null) {
complete(callback, new PageResult(new ArrayList<LogRecord>(), 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<LogRecord>(), Math.max(1, page), 1, 0);
}
complete(callback, result);
}
});
}
private PageResult queryInternal(String where, List<Object> 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<LogRecord> records = new ArrayList<LogRecord>();
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<LogRecord> 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<Object> 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;
}
}

View File

@@ -0,0 +1,8 @@
package com.yaohun.containerlog.database;
import com.yaohun.containerlog.model.PageResult;
public interface QueryCallback {
void onComplete(PageResult result);
}

View File

@@ -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<UUID, ContainerSession> sessions = new HashMap<UUID, ContainerSession>();
private final Map<UUID, ClickedChest> clickedChests = new HashMap<UUID, ClickedChest>();
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<ItemChange> 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<LogRecord> toRecords(ContainerSession session, List<ItemChange> changes) throws IOException {
List<LogRecord> records = new ArrayList<LogRecord>();
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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,35 @@
package com.yaohun.containerlog.model;
import java.util.Collections;
import java.util.List;
public class PageResult {
private final List<LogRecord> records;
private final int page;
private final int totalPages;
private final int total;
public PageResult(List<LogRecord> records, int page, int totalPages, int total) {
this.records = records == null ? Collections.<LogRecord>emptyList() : records;
this.page = page;
this.totalPages = totalPages;
this.total = total;
}
public List<LogRecord> getRecords() {
return records;
}
public int getPage() {
return page;
}
public int getTotalPages() {
return totalPages;
}
public int getTotal() {
return total;
}
}

View File

@@ -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<Location> resolveLinkedChestLocations(Block block) {
Map<String, Location> locations = new LinkedHashMap<String, Location>();
if (!isChestBlock(block)) {
return new ArrayList<Location>();
}
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<Location>(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<String, Location> locations, Location location) {
String key = location.getWorld().getName() + ":" + location.getBlockX() + ":" + location.getBlockY() + ":" + location.getBlockZ();
locations.put(key, toBlockLocation(location));
}
}

View File

@@ -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<ItemChange> diff(ItemStack[] before, ItemStack[] after) throws IOException {
Map<String, StackCounter> beforeMap = count(before);
Map<String, StackCounter> afterMap = count(after);
Set<String> keys = new HashSet<String>();
keys.addAll(beforeMap.keySet());
keys.addAll(afterMap.keySet());
List<ItemChange> changes = new ArrayList<ItemChange>();
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<String, StackCounter> count(ItemStack[] contents) throws IOException {
Map<String, StackCounter> counters = new HashMap<String, StackCounter>();
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;
}
}
}

View File

@@ -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<Map.Entry<Enchantment, Integer>> entries = new ArrayList<Map.Entry<Enchantment, Integer>>(itemStack.getEnchantments().entrySet());
Collections.sort(entries, new Comparator<Map.Entry<Enchantment, Integer>>() {
@Override
public int compare(Map.Entry<Enchantment, Integer> first, Map.Entry<Enchantment, Integer> second) {
return first.getKey().getName().compareTo(second.getKey().getName());
}
});
List<String> parts = new ArrayList<String>();
for (Map.Entry<Enchantment, Integer> entry : entries) {
parts.add(entry.getKey().getName() + ":" + entry.getValue());
}
return joinLines(parts);
}
private static String joinLines(List<String> 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();
}
}

View File

@@ -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<SimpleDateFormat> FORMAT = new ThreadLocal<SimpleDateFormat>() {
@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));
}
}

View File

@@ -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

View File

@@ -0,0 +1,16 @@
name: AuroraContainerLog
main: com.yaohun.containerlog.CLMain
version: 1.1.5
author: yaohun
description: 记录指定世界箱子容器的物品放入和取出日志
commands:
acl:
description: AuroraContainerLog 管理命令
usage: /acl <reload|player|chest|near>
permission: auroracontainerlog.admin
aliases:
- aclog
permissions:
auroracontainerlog.admin:
description: 允许查询和管理箱子日志
default: op