批次B3异步化 + 任务48改判系统/注册校验 + 任务50无感切换
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m6s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s

批次B3(混合型定时器拆分):
- Main: quest PlayTime 计时器 DB 下沉异步
- Main: 工单自动处理定时器改 runTaskTimerAsynchronously
- TicketManager: 新增 sync() 辅助,autoProcessCompleted/settleTicket/processDeleteAccount Bukkit 操作回主线程
- GarbageManager: doCleanup 两段式(DB删旧异步+收集回主线程),collectItems 落库异步
- SnManager: startAutoSweepTask 分片(每 tick 只清点 1 人)
- AreaProtection: sweepBannedOwnerLands 两阶段(Bukkit主线程判定 + DB/HTTP异步删除)

任务48(改判系统 + 注册校验):
- DatabaseManager: 新增 userExistsIgnoreCase(COLLATE NOCASE)
- Main: 新增 ensureRegistered 辅助,doTempBan/doBanIp 入口加注册校验
- Main: 新增 guardVanillaBan 守卫(HIGH优先级双事件),未注册拒绝+临时封禁改判永久

任务50(无感切换):
- Main: 新增 onSeamlessCommandSwitch(MONITOR),命中指令直接执行/大小写不敏感/未命中转公屏
- Main: 新增 getCommandMapSafe + forwardAsChat
This commit is contained in:
youpaishidifu
2026-10-03 15:36:31 +08:00
parent efe00d92af
commit 96aef279cf
10 changed files with 669 additions and 197 deletions
+53 -21
View File
@@ -9940,9 +9940,17 @@ public class AreaProtection implements Listener {
}
}
/**
* ★ 两阶段执行:
* 阶段1(主线程):只做 Bukkit 侧判定(封禁表 / 在线状态),不碰 DB;
* 阶段2(异步):userExists 判定 + deleteAreaFromDb 落库 + 触发 PHP 同步(含 HTTP)。
* 上报要遍历在线玩家,回主线程。
*/
private void sweepBannedOwnerLands() {
if (areas.isEmpty()) return;
DatabaseManager dbMgr = plugin.getDb();
// ---- 阶段1(主线程,纯 Bukkit 只读判定)----
final List<Object[]> candidates = new ArrayList<>();
for (AreaConfig ac : new ArrayList<>(areas.values())) {
if (ac == null || ac.owner == null || ac.owner.isEmpty()) continue;
// 快照遍历期间已被删除/重载的跳过
@@ -9964,31 +9972,55 @@ public class AreaProtection implements Listener {
}
} catch (Exception ignored) {}
// 3) 账户已注销(在线领主一律跳过,避免误删)
if (reason == null) {
if (dbMgr == null) continue;
// 3) 账户已注销(在线领主一律跳过,避免误删)—— 在线判定是 Bukkit API
Player ownerOnline = Bukkit.getPlayerExact(ac.owner);
if (ownerOnline != null && ownerOnline.isOnline()) continue;
try {
if (!dbMgr.userExists(ac.owner)) {
reason = "账户已注销";
}
} catch (Exception ignored) {}
}
if (reason == null) continue;
// 不退款、不注销账号,直接强制删除
cancelPendingDelete(ac.name);
deleteAreaFromDb(ac.name);
areas.remove(ac.name);
String coord = ac.world + " " + ac.x1 + "," + ac.z1 + " ~ " + ac.x2 + "," + ac.z2;
plugin.getLogger().warning("[防护] 自动清理领地 " + ac.name + ":领主 " + ac.owner
+ " 因" + reason + ",已强制删除(不退款)坐标 " + coord);
reportLandRemovalToAdmins("§6§l[防护-自动清理] §f领主 §e" + ac.owner
+ " §f因§c" + reason + " §f已自动删除其领地 §e" + ac.name
+ " §7(不退款) [" + coord + "]");
// reason 为 null = 还需异步阶段用 DB 判定是否注销
candidates.add(new Object[]{ac, reason});
}
if (candidates.isEmpty()) return;
final DatabaseManager dbMgr = plugin.getDb();
// ---- 阶段2(异步:DB 判定 + 删除落库 + PHP 同步)----
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
final List<String> reports = new ArrayList<>();
try {
for (Object[] o : candidates) {
AreaConfig ac = (AreaConfig) o[0];
String reason = (String) o[1];
if (reason == null) {
if (dbMgr == null) continue;
try {
if (!dbMgr.userExists(ac.owner)) {
reason = "账户已注销";
}
} catch (Exception ignored) {}
if (reason == null) continue;
}
if (areas.get(ac.name) != ac) continue;
// 不退款、不注销账号,直接强制删除
cancelPendingDelete(ac.name);
deleteAreaFromDb(ac.name);
areas.remove(ac.name);
String coord = ac.world + " " + ac.x1 + "," + ac.z1 + " ~ " + ac.x2 + "," + ac.z2;
plugin.getLogger().warning("[防护] 自动清理领地 " + ac.name + ":领主 " + ac.owner
+ " 因" + reason + ",已强制删除(不退款)坐标 " + coord);
reports.add("§6§l[防护-自动清理] §f领主 §e" + ac.owner
+ " §f因§c" + reason + " §f已自动删除其领地 §e" + ac.name
+ " §7(不退款) [" + coord + "]");
}
} catch (Exception e) {
plugin.getLogger().warning("[防护] 领地自动清理(异步阶段)异常: " + e.getMessage());
}
if (reports.isEmpty()) return;
// ---- 阶段3(回主线程上报:遍历在线玩家 + OP/管理员判定)----
Bukkit.getScheduler().runTask(plugin, () -> {
for (String m : reports) reportLandRemovalToAdmins(m);
});
});
}
/**
@@ -600,6 +600,29 @@ public class DatabaseManager {
}
}
/**
* ★ 封禁防护用:大小写不敏感的注册校验。
* 返回 false 仅表示「确认未注册」;SQL 异常时返回 true 并告警
* (宁可放行,也不因 DB 抖动误拒正常的封禁操作)。
*/
public boolean userExistsIgnoreCase(String name) {
if (name == null || name.isEmpty()) return false;
try {
PreparedStatement ps = db.prepareStatement(
"SELECT 1 FROM users "
+ "WHERE player_name=? "
+ "COLLATE NOCASE");
ps.setString(1, name);
boolean r = ps.executeQuery().next();
ps.close();
return r;
} catch (SQLException e) {
logger.warning("[封禁防护] 注册校验查询失败(放行): "
+ e.getMessage());
return true;
}
}
public void createUser(String name, String hash,
String salt) {
try {
+53 -28
View File
@@ -173,41 +173,56 @@ public class GarbageManager {
}
private void doCleanup() {
// 删除过期轮次的物品
int deleteRound =
// ★ 过期轮次删除是阻塞 DB -> 先异步执行;
// 轮次递增 / 实体收集 / 广播 必须主线程 -> 异步完成后回主线程
final int deleteRound =
currentRound - maxRounds;
if (deleteRound >= 0) {
int deleted = deleteOldItems(
deleteRound);
if (deleted > 0) {
broadcast("§7[垃圾站] §f永久清理了 §e"
+ deleted + " §f个过期物品");
}
}
Bukkit.getScheduler().runTaskAsynchronously(
plugin, () -> {
final int deleted =
deleteRound >= 0
? deleteOldItems(deleteRound)
: 0;
Bukkit.getScheduler().runTask(
plugin, () -> {
if (deleted > 0) {
broadcast("§7[垃圾站] §f永久清理了 §e"
+ deleted + " §f个过期物品");
}
// 递增轮次
currentRound++;
setConfigValue("round",
String.valueOf(currentRound));
// 递增轮次
currentRound++;
final int roundNow =
currentRound;
// ★ 轮次落库是 DB -> 异步
Bukkit.getScheduler()
.runTaskAsynchronously(
plugin, () ->
setConfigValue("round",
String.valueOf(roundNow)));
// 收集当前掉落物
int count = collectItems();
broadcast("§a§l[垃圾站] §f清理完成,"
+ "共清理 §e" + count
+ " §f个掉落物,存入回收站");
// 收集当前掉落物
// (实体移除必须主线程,落库已在内部转异步)
int count = collectItems();
broadcast("§a§l[垃圾站] §f清理完成,"
+ "共清理 §e" + count
+ " §f个掉落物,存入回收站");
// 打印下次清理时间
long nextMs = System.currentTimeMillis()
+ (long) cleanupInterval * 1000L;
String nextTime =
new SimpleDateFormat("HH:mm:ss")
.format(new Date(nextMs));
broadcast("§e§l[垃圾站] §f下次清理时间: §a"
+ nextTime);
// 打印下次清理时间
long nextMs = System.currentTimeMillis()
+ (long) cleanupInterval * 1000L;
String nextTime =
new SimpleDateFormat("HH:mm:ss")
.format(new Date(nextMs));
broadcast("§e§l[垃圾站] §f下次清理时间: §a"
+ nextTime);
});
});
}
public int collectItems() {
int count = 0;
List<ItemStack> picked = new ArrayList<>();
for (org.bukkit.World w :
Bukkit.getWorlds()) {
Collection<Item> items =
@@ -219,11 +234,21 @@ public class GarbageManager {
|| stack.getType()
== Material.AIR)
continue;
saveItem(stack);
// ★ 先取快照再移除实体(实体移除必须主线程)
picked.add(stack.clone());
item.remove();
count++;
}
}
// ★ 落库是阻塞 DB(含 SN 脱管登记)-> 丢异步批量写
if (!picked.isEmpty()) {
final List<ItemStack> toSave = picked;
Bukkit.getScheduler()
.runTaskAsynchronously(plugin, () -> {
for (ItemStack s : toSave)
saveItem(s);
});
}
return count;
}
@@ -70,11 +70,18 @@ public class InventoryAutoBackup {
return;
}
int level = p.getLevel();
double experience = p.getExp(); // 当前等级进度 0.0~1.0
plugin.getDb().saveAutoBackup(
name, contentsB64, armorB64, extraB64,
level, experience);
plugin.getLogger().info("[自动备份] " + name + " 背包已备份");
final int level = p.getLevel();
final double experience = p.getExp(); // 当前等级进度 0.0~1.0
// ★ 读背包/等级必须主线程;落库是阻塞 DB 写 -> 投递异步执行
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.getDb().saveAutoBackup(
name, contentsB64, armorB64, extraB64,
level, experience);
plugin.getLogger().info("[自动备份] " + name + " 背包已备份");
} catch (Throwable t) {
plugin.getLogger().warning("[自动备份] 落库失败 (" + name + "): " + t.getMessage());
}
});
}
}
+275 -15
View File
@@ -532,13 +532,22 @@ public class Main extends JavaPlugin
radio.startMainRadio();
new BukkitRunnable() {
public void run() {
if (questTracker == null) return;
final QuestTracker qt = questTracker;
if (qt == null) return;
// ★ QuestStorage.getCondition 是同步 SELECT(SQLITE_BUSY 时
// 会在调用线程 sleep 重试)-> 主线程只攒名单,DB 全部下沉异步
final List<String> names = new ArrayList<>();
for (Player p :
Bukkit.getOnlinePlayers()) {
if (!isFrozen(p))
questTracker.onPlayTime(
p.getName(), 30000L);
names.add(p.getName());
}
if (names.isEmpty()) return;
Bukkit.getScheduler()
.runTaskAsynchronously(Main.this, () -> {
for (String n : names)
qt.onPlayTime(n, 30000L);
});
}
}.runTaskTimer(this, 600L, 600L);
// [新增] Printer 文件清理(每小时检测一次)
@@ -547,7 +556,8 @@ public class Main extends JavaPlugin
if (bondPrinter != null)
bondPrinter.cleanupOldFiles();
}
}.runTaskTimer(this, 1200L, 72000L);
// ★ 纯文件 listFiles/delete -> 异步,不占主线程
}.runTaskTimerAsynchronously(this, 1200L, 72000L);
// 12 ===PVP===
pvpManager = new PVPManager(this);
@@ -670,7 +680,8 @@ public class Main extends JavaPlugin
// 这里简单采用「每小时检测一次,命中当天 0 点窗口即清理」过于繁琐,
// 故改为每日定时:延迟 1 小时后启动,之后每 24 小时执行一次。
long dayTicks = 24L * 60L * 60L * 20L;
getServer().getScheduler().runTaskTimer(this,
// ★ 纯 DB 清理 + 日志,主线程跑会随保留数据量增长而卡顿 -> 异步
getServer().getScheduler().runTaskTimerAsynchronously(this,
() -> {
try {
long cutoff = System.currentTimeMillis()
@@ -1009,8 +1020,11 @@ public class Main extends JavaPlugin
private void startLoginReminder() {
new BukkitRunnable() {
public void run() {
// ★ 主线程只做 Bukkit 操作(踢出/自动登录/在线判定),
// db.userExists 是同步 DB → 攒名单异步查,回主线程发提示
Iterator<UUID> it =
joinTime.keySet().iterator();
final List<String> needCheck = new ArrayList<>();
while (it.hasNext()) {
UUID uuid = it.next();
Player p = Bukkit.getPlayer(uuid);
@@ -1033,13 +1047,28 @@ public class Main extends JavaPlugin
continue;
}
// 已注册→提示登录,未注册→提示注册
if (db.userExists(p.getName()))
p.sendMessage(config.msg(
"not_logged_in"));
else
p.sendMessage(config.msg(
"not_registered"));
// 已注册→提示登录,未注册→提示注册(DB 查询下沉到异步)
needCheck.add(p.getName());
}
if (!needCheck.isEmpty()) {
Bukkit.getScheduler().runTaskAsynchronously(Main.this, () -> {
final Map<String, Boolean> existsMap = new LinkedHashMap<>();
for (String n : needCheck) {
existsMap.put(n, db.userExists(n));
}
Bukkit.getScheduler().runTask(Main.this, () -> {
for (Map.Entry<String, Boolean> e : existsMap.entrySet()) {
Player pl = Bukkit.getPlayerExact(e.getKey());
if (pl == null || !pl.isOnline()) continue;
if (loggedIn.contains(pl.getName())) continue;
pl.sendMessage(config.msg(
Boolean.TRUE.equals(e.getValue())
? "not_logged_in"
: "not_registered"));
}
});
});
}
}
}.runTaskTimer(this, 200L, 200L);
@@ -1067,10 +1096,13 @@ public class Main extends JavaPlugin
private void startTicketAutoProcess() {
new BukkitRunnable() {
public void run() {
if (ticket != null)
ticket.autoProcessCompleted();
// ★ autoProcessCompleted 是「多轮同步 DB 查询 + 写回」
// -> 整体异步;内部所有 Bukkit 操作自行回主线程
final TicketManager tk = ticket;
if (tk != null)
tk.autoProcessCompleted();
}
}.runTaskTimer(this, 6000L, 6000L);
}.runTaskTimerAsynchronously(this, 6000L, 6000L);
}
// ===== Utils =====
@@ -1281,6 +1313,33 @@ public class Main extends JavaPlugin
return t.contains(":");
}
/**
* ★ 注册校验:封禁目标必须在 login.db 的 users 表中已注册,
* 未注册直接中止并告知执行者「玩家不存在」。
* IP 目标(isIpTarget)不参与校验;DB 未就绪或查询异常时放行(只告警)。
* 返回 true = 放行;false = 已提示,调用方应直接 return。
*/
private boolean ensureRegistered(CommandSender sender, String target) {
if (target == null || target.isEmpty()) return true;
if (isIpTarget(target)) return true; // 纯 IP 不是玩家名
DatabaseManager d = getDb();
if (d == null) return true; // DB 未就绪不阻断封禁
boolean exists;
try {
exists = d.userExistsIgnoreCase(target);
} catch (Exception ex) {
getLogger().warning("[封禁防护] 注册校验异常(放行): "
+ ex.getMessage());
return true;
}
if (!exists) {
sender.sendMessage(
"§c§l[封禁] §f玩家不存在: §e" + target);
return false;
}
return true;
}
/** 统一封禁入口:根据目标自动选择 NAME / IP 封禁名单 */
private void applyBan(CommandSender sender, String target,
String reason, Long expireMs) {
@@ -1416,6 +1475,8 @@ public class Main extends JavaPlugin
return;
}
String target = args[0];
// ★ 安全防护:目标必须已注册,否则直接返回「玩家不存在」
if (!ensureRegistered(sender, target)) return;
String durStr = args[1];
StringBuilder reason = new StringBuilder();
for (int i = 2; i < args.length; i++) {
@@ -1473,6 +1534,8 @@ public class Main extends JavaPlugin
return;
}
String target = args[0];
// ★ 安全防护:目标必须已注册,否则直接返回「玩家不存在」
if (!ensureRegistered(sender, target)) return;
int reasonStart = 1;
if (args.length > 1
&& (args[1].equals("永久")
@@ -3396,6 +3459,203 @@ public class Main extends JavaPlugin
handleBanCommand(e.getCommand().trim());
}
// =========================================================
// ★ 任务50:无感切换 —— 服务端接管聊天栏 "/指令"
// · 命中服务器指令(含原版 + 插件)→ 直接执行
// · 指令名大小写不敏感(/SDF1_login 与 /sdf1_login 等价)
// · 未命中 → 取消事件(压制原生 "Unknown command"),
// 按普通聊天经插件聊天管道转发公屏
// 优先级 MONITOR:晚于 HIGHEST 的冻结守卫,
// 未登录玩家的未知命令已先被拦下,不会绕过登录变成聊天。
// =========================================================
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onSeamlessCommandSwitch(
org.bukkit.event.player.PlayerCommandPreprocessEvent e) {
if (e.isCancelled()) return;
Player p = e.getPlayer();
String msg = e.getMessage().trim();
if (msg.isEmpty() || msg.charAt(0) != '/') return;
String body = msg.substring(1).trim();
if (body.isEmpty()) return;
org.bukkit.command.CommandMap cm = getCommandMapSafe();
if (cm == null) return; // 拿不到命令映射 -> 维持原行为
String label = body.split(" ", 2)[0];
String lowerLabel = label.toLowerCase(java.util.Locale.ROOT);
// 命中判定统一用小写:SimpleCommandMap 的 key 本身就是小写
org.bukkit.command.Command hit = cm.getCommand(lowerLabel);
if (hit == null) hit = cm.getCommand("minecraft:" + lowerLabel);
if (hit != null) {
// ★ 指令大小写不敏感:原样 label 查不到但小写查得到
// -> 取消原事件,用小写 label 重新分发一次
if (!label.equals(lowerLabel)
&& cm.getCommand(label) == null) {
e.setCancelled(true);
String rest = body.substring(label.length());
try {
Bukkit.dispatchCommand(p, lowerLabel + rest);
} catch (Throwable t) {
getLogger().warning("[无感切换] 指令分发失败: "
+ t.getMessage());
}
}
return; // 命中 -> 直接执行,交还原版流程
}
// ★ 未命中:压制原生 "Unknown command",转普通聊天
e.setCancelled(true);
forwardAsChat(p, body);
}
/**
* 反射取命令映射(Bukkit.getCommandMap 为 Paper API,
* 反射可同时兼容 Spigot / CraftServer 实现)。拿不到返回 null。
*/
private org.bukkit.command.CommandMap getCommandMapSafe() {
try {
java.lang.reflect.Method m =
Bukkit.class.getMethod("getCommandMap");
Object cm = m.invoke(null);
if (cm instanceof org.bukkit.command.CommandMap)
return (org.bukkit.command.CommandMap) cm;
} catch (Throwable ignored) {
}
try {
java.lang.reflect.Method m = Bukkit.getServer().getClass()
.getMethod("getCommandMap");
Object cm = m.invoke(Bukkit.getServer());
if (cm instanceof org.bukkit.command.CommandMap)
return (org.bukkit.command.CommandMap) cm;
} catch (Throwable ignored) {
}
return null;
}
/**
* 把「未命中指令」的文本当作玩家聊天,走插件既有聊天管道
* (验证码 / 敏感词过滤 / 自定义格式 / 其它插件监听)。
*/
private void forwardAsChat(Player p, String text) {
if (text == null || text.isEmpty()) return;
try {
java.util.Set<Player> rcpt =
new java.util.HashSet<>(Bukkit.getOnlinePlayers());
org.bukkit.event.player.AsyncPlayerChatEvent ev =
new org.bukkit.event.player.AsyncPlayerChatEvent(
false, p, text, rcpt);
Bukkit.getPluginManager().callEvent(ev);
if (ev.isCancelled()) return;
String fmt = ev.getFormat();
// 新版 API 中 recipients 可能为空 -> 兜底全服
java.util.Set<Player> rs = ev.getRecipients();
if (rs == null || rs.isEmpty())
rs = new java.util.HashSet<>(Bukkit.getOnlinePlayers());
for (Player r : rs) {
r.sendMessage(String.format(fmt,
p.getDisplayName(), ev.getMessage()));
}
} catch (Throwable t) {
getLogger().warning("[无感切换] 转发聊天失败: "
+ t.getMessage());
Bukkit.broadcastMessage(p.getDisplayName() + "§r: " + text);
}
}
// =========================================================
// ★ 任务48:改判系统 + 注册校验(原版 /ban 执行前拦截)
// 优先级 HIGH —— 早于 MONITOR 的 handleBanCommand,
// 取消后原版命令根本不执行 => 天然压制 MC 原生提示。
// =========================================================
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onPlayerBanGuard(
org.bukkit.event.player.PlayerCommandPreprocessEvent e) {
if (e.isCancelled()) return;
String msg = e.getMessage().trim();
if (msg.isEmpty() || msg.charAt(0) != '/') return;
if (!guardVanillaBan(e.getPlayer(), msg.substring(1)))
e.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onConsoleBanGuard(
org.bukkit.event.server.ServerCommandEvent e) {
if (e.isCancelled()) return;
if (!guardVanillaBan(e.getSender(), e.getCommand().trim()))
e.setCancelled(true);
}
/**
* 原版 /ban、/ban-ip 前置守卫。
* 返回 true = 放行交回原版;false = 已处理/已拒绝(调用方取消事件)。
*/
private boolean guardVanillaBan(CommandSender sender,
String rawCommand) {
if (rawCommand == null || rawCommand.isEmpty()) return true;
String lower = rawCommand.toLowerCase();
// 兼容 minecraft:ban / minecraft:ban-ip 形式
if (lower.startsWith("minecraft:")) {
rawCommand = rawCommand.substring(10);
lower = lower.substring(10);
}
boolean isNameBan = lower.equals("ban")
|| lower.startsWith("ban ");
boolean isIpBan = lower.equals("ban-ip")
|| lower.startsWith("ban-ip ")
|| lower.equals("banip")
|| lower.startsWith("banip ");
if (!isNameBan && !isIpBan) return true; // 非封禁命令放行
String[] parts = rawCommand.split("\\s+");
if (parts.length < 2) return true; // 参数不足交回原版提示
String target = parts[1];
if (isIpTarget(target)) return true; // 纯 IP 目标不校验注册
// 无权限玩家直接交回原版(原版自行拒绝),不泄露注册信息
if (sender instanceof Player && !isPrivileged(sender)
&& !sender.hasPermission("minecraft:ban")
&& !sender.hasPermission("minecraft:op")) {
return true;
}
// ---- 1) 注册校验:未注册 -> 拒绝并提示 ----
if (!ensureRegistered(sender, target)) return false;
// ---- 2) 改判:目标处于「生效中的临时封禁」时,
// 原版 /ban 直接改判为永久封禁 ----
if (isNameBan) {
try {
org.bukkit.BanEntry<?> old =
Bukkit.getBanList(org.bukkit.BanList.Type.NAME)
.getBanEntry(target);
java.util.Date exp = old == null
? null : old.getExpiration();
if (exp != null
&& exp.getTime() > System.currentTimeMillis()) {
StringBuilder rb = new StringBuilder();
for (int i = 2; i < parts.length; i++) {
if (rb.length() > 0) rb.append(' ');
rb.append(parts[i]);
}
String reason = rb.length() > 0 ? rb.toString()
: "违反服务器规则(临时封禁改判为永久)";
// 取消原版执行 -> 原生 "Banned player ..." 不再出现
applyBan(sender, target, reason, null);
sender.sendMessage(
"§6§l[改判] §f目标 §e" + target
+ " §f正处于临时封禁中,"
+ "§c已直接改判为永久封禁");
return false;
}
} catch (Exception ex) {
getLogger().warning("[改判] 读取封禁条目异常: "
+ ex.getMessage());
}
}
return true;
}
private void handleBanCommand(String rawCommand) {
if (rawCommand.isEmpty()) return;
String lower = rawCommand.toLowerCase();
+16 -2
View File
@@ -1340,10 +1340,22 @@ public class OrderManager implements Listener {
} if (cur != null && cur.uuid != null) orders.add(cur); br.close();
} catch (Exception e) { plugin.getLogger().warning("[Order]加载失败: " + e.getMessage()); }
}
/** 写盘互斥锁:与 orders 锁分离,避免持 orders 锁做文件 IO(异步清理写盘时主线程 add 会被卡) */
private final Object saveLock = new Object();
private void saveOrders() {
// ★ 先在 orders 锁内取快照(极快),再持 saveLock 写盘
// ① 主线程 recordOrder 的 add 不会被慢速写盘阻塞
// ② 主线程与异步清理任务不会同时写 orders.dat 互相截断
final List<OrderRecord> snap;
synchronized (orders) { snap = new ArrayList<>(orders); }
synchronized (saveLock) { saveOrdersTo(snap); }
}
private void saveOrdersTo(List<OrderRecord> snap) {
File f = new File(plugin.getDataFolder(), "orders.dat");
try { PrintWriter pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(f), StandardCharsets.UTF_8));
synchronized (orders) { for (OrderRecord r : orders) { pw.println("==");
{ for (OrderRecord r : snap) { pw.println("==");
pw.println("id=" + r.orderId); pw.println("uuid=" + r.uuid); pw.println("player=" + r.player);
pw.println("totalOriginal=" + r.totalOriginal); pw.println("totalPaid=" + r.totalPaid);
pw.println("discount=" + r.discount); pw.println("discountType=" + nn(r.discountType));
@@ -1355,7 +1367,9 @@ public class OrderManager implements Listener {
} } pw.flush(); pw.close();
} catch (Exception e) { plugin.getLogger().warning("[Order]保存失败: " + e.getMessage()); }
}
private void startCleanupTask() { new BukkitRunnable() { public void run() { synchronized (orders) { if (orders.removeIf(OrderManager.this::isExpired)) saveOrders(); } } }.runTaskTimer(plugin, 36000L, 36000L); }
// ★ 过期清理 = 遍历 + 全量落盘(文件 IO)→ 异步定时器;
// removeIf 在 orders 锁内完成即释放,写盘走 saveLock,不长期占着 orders 锁
private void startCleanupTask() { new BukkitRunnable() { public void run() { boolean changed; synchronized (orders) { changed = orders.removeIf(OrderManager.this::isExpired); } if (changed) saveOrders(); } }.runTaskTimerAsynchronously(plugin, 36000L, 36000L); }
/**
* 将小票书塞入潜影盒
*/
+9 -1
View File
@@ -125,7 +125,15 @@ public class QuickBack implements Listener, CommandExecutor, TabCompleter {
private void recordLocation(Player player) {
Location loc = player.getLocation();
lastKnownLocations.put(player.getName(), loc);
saveToDB(player.getName(), loc);
// ★ 读坐标/更新内存缓存留主线程;写库是阻塞 DB 操作 -> 异步
final String name = player.getName();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
saveToDB(name, loc);
} catch (Throwable t) {
plugin.getLogger().warning("[快捷返回] 坐标落库失败 (" + name + "): " + t.getMessage());
}
});
}
public Location getLastKnownLocation(Player player) {
+24 -3
View File
@@ -1416,14 +1416,33 @@ public class SnManager implements Listener {
*/
private void startAutoSweepTask() {
try {
// ★ autoSweep 必须主线程(读写玩家背包),且内部含多次 SQLite 点查。
// 原实现一 tick 内清点「全部」在线玩家,人数一多就是主线程尖峰 ->
// 改为「每 tick 只清点 1 名玩家」的轮转分片,仍保持约 60 秒一轮:
// 单 tick 成本 = 1 名玩家,而非全服。
final int[] cursor = {0};
final long[] roundStart =
{System.currentTimeMillis()};
Bukkit.getScheduler().runTaskTimer(plugin, new Runnable() {
@Override
public void run() {
long now = System.currentTimeMillis();
// 每 60 秒重开一轮
if (now - roundStart[0] >= 60_000L) {
cursor[0] = 0;
roundStart[0] = now;
}
List<Player> online =
new ArrayList<>(Bukkit.getOnlinePlayers());
for (Player p : online) autoSweep(p, false);
if (online.isEmpty()) {
cursor[0] = 0;
return;
}
if (cursor[0] >= online.size()) return; // 本轮已清点完
Player p = online.get(cursor[0]++);
autoSweep(p, false);
}
}, 200L, 1200L);
}, 200L, 1L);
} catch (Throwable t) {
throttleErr("sweeptimer:" + t.getMessage());
}
@@ -1438,7 +1457,9 @@ public class SnManager implements Listener {
*/
private void startCustodySweepTask() {
try {
Bukkit.getScheduler().runTaskTimer(plugin, new Runnable() {
// ★ sweepCustody 是纯 DB(UPDATE + SELECT),改异步定时器
// 内部 lostReported 是 ConcurrentHashMap、日志线程安全
Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, new Runnable() {
@Override
public void run() {
sweepCustody();
+91 -65
View File
@@ -704,6 +704,11 @@ public class TicketManager {
"§e§l===================");
}
/**
* ★ 由异步定时器调用(Main.startTicketAutoProcess)。
* DB 查询/写回留在当前(异步)线程;
* 取玩家 / 发消息 / 备份背包 / 踢人 一律经 sync() 回主线程。
*/
public void autoProcessCompleted() {
long timeoutMs = 10 * 60 * 1000L;
@@ -737,25 +742,26 @@ public class TicketManager {
.getUnconfirmedCompleted(
timeoutMs);
for (Map<String, Object> t : list) {
int id = nv(t, "id");
final int id = nv(t, "id");
plugin.getDb().confirmTicket(id, 3);
Player rp = Bukkit.getPlayer(
sv(t, "requester"));
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id + " 已自动完结");
String assigned =
sv(t, "assigned_to");
if (!assigned.isEmpty()) {
Player pp =
Bukkit.getPlayer(assigned);
if (pp != null && pp.isOnline())
pp.sendMessage(
final String req = sv(t, "requester");
final String asg = sv(t, "assigned_to");
sync(() -> {
Player rp = Bukkit.getPlayer(req);
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id
+ " 已自动完结");
}
+ id + " 已自动完结");
if (!asg.isEmpty()) {
Player pp =
Bukkit.getPlayer(asg);
if (pp != null && pp.isOnline())
pp.sendMessage(
"§e§l[工单] §f工单 #"
+ id
+ " 已自动完结");
}
});
settleTicket(id);
}
@@ -765,14 +771,16 @@ public class TicketManager {
.getRepliedStaleTickets(
timeoutMs);
for (Map<String, Object> t : stale) {
int id = nv(t, "id");
final int id = nv(t, "id");
plugin.getDb().confirmTicket(id, 3);
Player rp = Bukkit.getPlayer(
sv(t, "requester"));
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id + " 超时自动完结");
final String req = sv(t, "requester");
sync(() -> {
Player rp = Bukkit.getPlayer(req);
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id + " 超时自动完结");
});
settleTicket(id);
}
}
@@ -1266,13 +1274,23 @@ public class TicketManager {
// ========== 私有 ==========
/**
* 把 Bukkit 操作切回主线程执行。
* 已在主线程时立即执行(保持原调用方的同步语义)。
* 供异步定时器路径(autoProcessCompleted / settleTicket / processDeleteAccount)使用。
*/
private void sync(Runnable r) {
if (Bukkit.isPrimaryThread()) r.run();
else Bukkit.getScheduler().runTask(plugin, r);
}
private void settleTicket(int tid) {
Map<String, Object> t =
plugin.getDb().getTicket(tid);
if (t == null) return;
String requester =
final String requester =
sv(t, "requester");
String assigned =
final String assigned =
sv(t, "assigned_to");
int reward = nv(t, "reward_amount");
int score = nv(t, "score");
@@ -1285,14 +1303,16 @@ public class TicketManager {
requester, reward,
BondManager.TX_TICKET_REWARD, "",
"System", "工单 #" + tid + " 奖励");
Player rp = Bukkit.getPlayer(
requester);
if (rp != null && rp.isOnline()) {
rp.sendMessage(
"§e§l[工单] §f获得 "
+ reward
+ " 债券奖励");
}
sync(() -> {
Player rp = Bukkit.getPlayer(
requester);
if (rp != null && rp.isOnline()) {
rp.sendMessage(
"§e§l[工单] §f获得 "
+ reward
+ " 债券奖励");
}
});
}
// ★ 服务商获得债券奖励
@@ -1307,17 +1327,20 @@ public class TicketManager {
+ " 服务奖励(评分"
+ score + ")");
}
Player pp =
Bukkit.getPlayer(assigned);
if (pp != null && pp.isOnline()) {
pp.sendMessage(
"§a§l[工单] §f工单 #"
+ tid
+ " 确认完结");
if (bondReward > 0)
pp.sendMessage("§7债券: §e"
+ bondReward + " 枚");
}
final int br = bondReward;
sync(() -> {
Player pp =
Bukkit.getPlayer(assigned);
if (pp != null && pp.isOnline()) {
pp.sendMessage(
"§a§l[工单] §f工单 #"
+ tid
+ " 确认完结");
if (br > 0)
pp.sendMessage("§7债券: §e"
+ br + " 枚");
}
});
}
}
@@ -1405,31 +1428,34 @@ public class TicketManager {
*/
public void processDeleteAccount(int tid,
Map<String, Object> t) {
String requester = sv(t, "requester");
final String requester = sv(t, "requester");
plugin.getDb().updateTicketStatus(
tid, "resolved");
plugin.getDb().updateTicketField(tid,
"admin_confirmed", 1);
Player target =
Bukkit.getPlayerExact(requester);
if (target != null && target.isOnline()) {
plugin.backupInventory(target);
target.sendMessage(
"§a§l[工单] §f您的删号请求已通过");
target.sendMessage(
"§7物品已备份,下次登录可取回");
target.kickPlayer(
"§a删号成功,物品已备份。\n"
+ "请等待5分钟后重新注册取回物品。");
} else {
// 离线:仅标记,不操作数据库
plugin.getLogger().info(
"[Sdf1_login] " + requester
+ " 删号工单已通过,等待上线执行");
}
// 延迟执行删号
// ★ 备份背包 / 发消息 / 踢人 必须主线程
sync(() -> {
Player target =
Bukkit.getPlayerExact(requester);
if (target != null && target.isOnline()) {
plugin.backupInventory(target);
target.sendMessage(
"§a§l[工单] §f您的删号请求已通过");
target.sendMessage(
"§7物品已备份,下次登录可取回");
target.kickPlayer(
"§a删号成功,物品已备份。\n"
+ "请等待5分钟后重新注册取回物品。");
} else {
// 离线:仅标记,不操作数据库
plugin.getLogger().info(
"[Sdf1_login] " + requester
+ " 删号工单已通过,等待上线执行");
}
});
// 延迟执行删号(DB 写 -> 异步,不占主线程)
final String name = requester;
Bukkit.getScheduler().runTaskLater(
Bukkit.getScheduler().runTaskLaterAsynchronously(
plugin, () -> {
int deleted =
plugin.getDb().deleteUser(name);
+112 -56
View File
@@ -3460,33 +3460,44 @@ public class WebManager {
/**
* 4. 注册账号到Web端
* ★ 内部异步:调用点在主线程(Web注册回调 runTask 里),
* HTTP 同步(token + register.php POST,10s 超时)会卡死 tick。
*/
public void syncRegistration(String playerName, String passwordHash, String salt, String email, String ip) {
if (!enabled) return;
try {
String token = generateAndSyncToken(playerName, "register");
// 参数在 lambda 内只读,effectively final;email/ip 可能为 null,先落地
final String fPlayer = playerName;
final String fHash = passwordHash;
final String fSalt = salt;
final String fEmail = email != null ? email : "";
final String fIp = ip != null ? ip : "";
Map<String, Object> body = new LinkedHashMap<>();
body.put("player", playerName);
body.put("password_hash", passwordHash);
body.put("salt", salt);
body.put("email", email != null ? email : "");
body.put("ip", ip != null ? ip : "");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
String token = generateAndSyncToken(fPlayer, "register");
String response = httpPostWithToken("api/register.php?action=register", token, body);
if (response != null) {
Map<String, Object> result = parseJson(response);
Boolean success = (Boolean) result.get("success");
if (Boolean.TRUE.equals(success)) {
plugin.getLogger().info("[Web通信] 注册同步成功: " + playerName);
} else {
plugin.getLogger().warning("[Web通信] 注册同步失败: " + result.get("message"));
Map<String, Object> body = new LinkedHashMap<>();
body.put("player", fPlayer);
body.put("password_hash", fHash);
body.put("salt", fSalt);
body.put("email", fEmail);
body.put("ip", fIp);
String response = httpPostWithToken("api/register.php?action=register", token, body);
if (response != null) {
Map<String, Object> result = parseJson(response);
Boolean success = (Boolean) result.get("success");
if (Boolean.TRUE.equals(success)) {
plugin.getLogger().info("[Web通信] 注册同步成功: " + fPlayer);
} else {
plugin.getLogger().warning("[Web通信] 注册同步失败: " + result.get("message"));
}
}
} catch (Exception e) {
plugin.getLogger().warning("[Web通信] 注册同步异常: " + e.getMessage());
}
} catch (Exception e) {
plugin.getLogger().warning("[Web通信] 注册同步异常: " + e.getMessage());
}
});
}
/**
@@ -6608,11 +6619,12 @@ public class WebManager {
// 标记为已处理
processedWebLoginRequests.put(reqId, System.currentTimeMillis());
// 在主线程验证密码
// ★ 异步验证密码:handleWebPasswordVerify 是纯 DB(login.db),
// 原来 runTask 到主线程跑同步查询,数据量涨起来会顶 tick
final String fReqId = reqId;
final String fName = playerName;
final String fPwd = password;
Bukkit.getScheduler().runTask(plugin, () -> {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
String result = plugin.handleWebPasswordVerify(fName, fPwd);
plugin.getLogger().info("[Web密码验证] 结果: player=" + fName + " result=" + result);
@@ -6926,7 +6938,9 @@ public class WebManager {
// 现在:拉取→立即ack(写confirmed表)→处理→confirm(保险)
ackTransaction(txId, type);
// 在主线程处理交易
// 在主线程处理交易(Bukkit 发货/背包操作必须主线程)
// ★ 交易内的 HTTP(confirm/refund)已由 confirmTransaction/
// writeRefundTransaction 改为异步投递,主线程不再等待网络。
final String fTxId = txId;
final String fName = playerName;
final String fType = type;
@@ -7645,7 +7659,15 @@ public class WebManager {
}
/**
* 确认交易已处理(同步调用,确保PHP收到确认后才返回,防止重启后重复发货)
* 确认交易已处理。
*
* <p>★ 2026-10-03 主线程零HTTP改造:本地去重标记仍然同步写入,
* 但上报 PHP 的 doPost(HttpClient.send,10 秒超时)改丢异步线程。
* 原实现被 processWebTransaction 调用,而后者是被
* Bukkit.getScheduler().runTask 派回主线程执行的 —— 后端一慢,
* 主线程就被 cfHttpClient.send 卡满 10 秒,触发
* "The server has not responded for 10 seconds" 线程转储(线上报错根因)。
* 重启防重不受影响:confirmedTxIds 同步写入 + 拉取阶段 ackTransaction 已先行落库。
*/
private void confirmTransaction(String txId) {
try {
@@ -7653,27 +7675,53 @@ public class WebManager {
String bodyJson = "{\"tx_id\":\"" + txId + "\"}";
String postUrl = webBaseUrl + "/api/sync.php?action=confirm_transaction&secret="
+ java.net.URLEncoder.encode(secretKey, "UTF-8");
doPost(postUrl, bodyJson);
postAsyncFireAndForget("[Web交易] 确认交易 " + txId, postUrl, bodyJson);
} catch (Exception e) {
plugin.getLogger().warning("[Web交易] 确认交易 " + txId + " 失败(已标记本地已确认,不会重复处理): " + e.getMessage());
}
}
/**
* 异步 POST 上报(fire-and-forget):结果只用于记日志的场合,
* 绝不在主线程同步等待 HTTP。
*/
private void postAsyncFireAndForget(final String tag, final String url, final String body) {
try {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
doPost(url, body);
} catch (Exception e) {
plugin.getLogger().warning(tag + " 异步上报失败: " + e.getMessage());
}
});
} catch (Throwable t) {
plugin.getLogger().warning(tag + " 无法投递异步上报: " + t.getMessage());
}
}
/**
* 写入退款记录到 PHP 端(通过 sync.php),让 PHP 自动退款+恢复库存。
* 用于购物车发货部分/全部失败时的整单退款。
*/
private void writeRefundTransaction(String playerName, int amount, String origTxId, String reason, String payMode) {
private void writeRefundTransaction(final String playerName, final int amount, final String origTxId,
final String reason, final String payMode) {
try {
String bodyJson = "{\"player_name\":\"" + playerName
final String bodyJson = "{\"player_name\":\"" + playerName
+ "\",\"amount\":" + amount
+ ",\"orig_tx_id\":\"" + origTxId
+ "\",\"reason\":\"" + reason.replace("\"", "'")
+ "\",\"pay_mode\":\"" + (payMode != null ? payMode : "bond") + "\"}";
String postUrl = webBaseUrl + "/api/sync.php?action=write_shop_refund&secret="
final String postUrl = webBaseUrl + "/api/sync.php?action=write_shop_refund&secret="
+ java.net.URLEncoder.encode(secretKey, "UTF-8");
String resp = doPost(postUrl, bodyJson);
plugin.getLogger().info("[Web交易] 已写入退款记录: 玩家=" + playerName + " 金额=" + amount + " 原交易#" + origTxId + " 响应=" + resp);
// ★ 主线程零HTTP:退款上报同样丢异步(结果仅记日志,不影响发货流程)
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
String resp = doPost(postUrl, bodyJson);
plugin.getLogger().info("[Web交易] 已写入退款记录: 玩家=" + playerName + " 金额=" + amount + " 原交易#" + origTxId + " 响应=" + resp);
} catch (Exception e) {
plugin.getLogger().warning("[Web交易] 写入退款记录失败: " + e.getMessage());
}
});
} catch (Exception e) {
plugin.getLogger().warning("[Web交易] 写入退款记录失败: " + e.getMessage());
}
@@ -7919,15 +7967,21 @@ public class WebManager {
* 直接写入 PHP shop_config 表(secret 认证),随后由 pullShopConfig 定时器刷新本地缓存
*/
public void pushShopConfig(String key, String value) {
try {
String urlStr = webBaseUrl + "/api/sync.php?action=set_shop_config&secret="
+ java.net.URLEncoder.encode(secretKey, "UTF-8")
+ "&key=" + java.net.URLEncoder.encode(key, "UTF-8")
+ "&value=" + java.net.URLEncoder.encode(value, "UTF-8");
doGet(urlStr);
} catch (Exception e) {
plugin.getLogger().warning("[配置推送] 保存商店配置失败: " + e.getMessage());
}
// ★ 调用点在命令处理(主线程):doGet 有 10s 超时,同步执行会卡死 tick
// 沿用项目惯例走 submitWebTask(webExecutor),熔断打开时静默跳过
final String fKey = key;
final String fValue = value;
submitWebTask("pushShopConfig-" + fKey, () -> {
try {
String urlStr = webBaseUrl + "/api/sync.php?action=set_shop_config&secret="
+ java.net.URLEncoder.encode(secretKey, "UTF-8")
+ "&key=" + java.net.URLEncoder.encode(fKey, "UTF-8")
+ "&value=" + java.net.URLEncoder.encode(fValue, "UTF-8");
doGet(urlStr);
} catch (Exception e) {
plugin.getLogger().warning("[配置推送] 保存商店配置失败: " + e.getMessage());
}
});
}
/**
@@ -8442,7 +8496,8 @@ public class WebManager {
plugin.getLogger().info("[Web注册] 处理Web注册请求: " + playerName + " (ID:" + reqId + ")");
// 在主线程创建游戏账号
// ★ 异步创建游戏账号:主体是 DB 写 + HTTP 同步,
// 只有"在线玩家自动登录"必须回主线程(Bukkit API)
final String fReqId = reqId;
final String fName = playerName;
final String fHash = passwordHash;
@@ -8450,7 +8505,7 @@ public class WebManager {
final String fEmail = email != null ? email : "";
final String fIp = ipAddress != null ? ipAddress : "";
Bukkit.getScheduler().runTask(plugin, () -> {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
// 检查玩家是否已注册
DatabaseManager dbMgr = plugin.getDb();
@@ -8485,19 +8540,19 @@ public class WebManager {
dbMgr.setField(fName, "register_ip", fIp);
}
// ★ 如果玩家当前在线,自动登录
Player onlinePlayer = Bukkit.getPlayer(fName);
if (onlinePlayer != null && onlinePlayer.isOnline()) {
plugin.getLogger().info("[Web注册] 玩家 " + fName + " 当前在线,执行自动登录");
Bukkit.getScheduler().runTask(plugin, () -> {
// ★ 如果玩家当前在线,自动登录 —— Bukkit API 必须主线程
Bukkit.getScheduler().runTask(plugin, () -> {
Player onlinePlayer = Bukkit.getPlayer(fName);
if (onlinePlayer != null && onlinePlayer.isOnline()) {
plugin.getLogger().info("[Web注册] 玩家 " + fName + " 当前在线,执行自动登录");
plugin.autoLogin(onlinePlayer, "web_register");
onlinePlayer.sendMessage("§a[Sdf1_login] §fWeb注册成功,已自动登录!");
});
} else {
plugin.getLogger().info("[Web注册] 玩家 " + fName + " 当前不在线");
}
} else {
plugin.getLogger().info("[Web注册] 玩家 " + fName + " 当前不在线");
}
});
// 同步注册到Web端
// 同步注册到Web端(方法内部已异步)
syncRegistration(fName, fHash, fSalt, fIp, fEmail);
// 同步密码凭证到Web端(通过DB队列)
@@ -8591,7 +8646,8 @@ public class WebManager {
plugin.getLogger().info("[Web注册] 发现未同步用户: " + fName + ",正在补全到本地数据库");
Bukkit.getScheduler().runTask(plugin, () -> {
// ★ 异步补全:DB 写 + HTTP 同步;自动登录回主线程
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
dbMgr.createUser(fName, fHash, fSalt);
plugin.getLogger().info("[Web注册] 补全成功: " + fName);
@@ -8602,13 +8658,13 @@ public class WebManager {
dbMgr.setField(fName, "register_ip", fIp);
}
Player onlinePlayer = Bukkit.getPlayer(fName);
if (onlinePlayer != null && onlinePlayer.isOnline()) {
Bukkit.getScheduler().runTask(plugin, () -> {
Bukkit.getScheduler().runTask(plugin, () -> {
Player onlinePlayer = Bukkit.getPlayer(fName);
if (onlinePlayer != null && onlinePlayer.isOnline()) {
plugin.autoLogin(onlinePlayer, "web_register");
onlinePlayer.sendMessage("§a[Sdf1_login] §fWeb注册成功,已自动登录!");
});
}
}
});
syncRegistration(fName, fHash, fSalt, fIp, fEmail);
submitDbTask("补全-pushWebLoginCredentials", () -> pushWebLoginCredentials());