第十四批:控制台execute链@s拦截与单斜杠中文化、二手批量上架成功跳过计数、OP白名单定时器墙钟自对齐与ops.json先备份后撤销、撤销提示病句与最小化信息披露
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s

This commit is contained in:
youpaishidifu
2026-10-14 13:59:16 +08:00
parent 9cd85013d5
commit 2d9dd5b438
4 changed files with 202 additions and 25 deletions
+58 -3
View File
@@ -3831,8 +3831,48 @@ public class Main extends JavaPlugin
String[] toks = raw.split("\\s+");
String head = toks[0].toLowerCase(java.util.Locale.ROOT);
if (head.startsWith("minecraft:")) head = head.substring(10);
// execute ... as ... run ... 链里的 @s 指向被 as 的实体,合法,不拦
if (head.equals("execute")) return;
// execute 链静态扫描:@s 出现在任何 as 之前 = 指向控制台自身 → 拦截提示;
// 出现在 as 之后 = 指向被 as 的实体,合法放行。
// ★ 问题1(2026-10-14):原实现对 head=execute 一刀切 return,
// execute at @s run say 123 这类从控制台发出原版【静默无输出】,
// 插件完全没监管 —— 现扫到就拦,给中文改法提示。
if (head.equals("execute")) {
int eIdx = -1;
boolean asSeen = false;
for (int i = 1; i < toks.length; i++) {
String t = toks[i].toLowerCase(java.util.Locale.ROOT);
if (t.equals("as")) {
asSeen = true; // execute as / positioned as / rotated as 的 as 都算
continue;
}
if (t.equals("run")) {
if (i + 1 >= toks.length) break;
String nx = toks[i + 1].toLowerCase(java.util.Locale.ROOT);
if (nx.startsWith("minecraft:")) nx = nx.substring(10);
// run 后只有仍嵌套 execute 才继续扫;say/me/help 等文本命令里
// 的字面 @s 不是选择器,直接停(防误伤)
if (!nx.equals("execute")) break;
i++; // 跳过内层 execute 本身,继续扫下一段链
continue;
}
if (!asSeen && (toks[i].equals("@s") || toks[i].startsWith("@s["))) {
eIdx = i;
break;
}
}
if (eIdx < 0) return;
e.setCancelled(true);
cs.sendMessage("§c§l[命令未执行] §f/" + raw);
cs.sendMessage("§c原因: §f第 " + eIdx + " 个参数 §e" + toks[eIdx]
+ " §f是 @s(=执行者自己),但控制台不是玩家,没有“自己”可指。");
cs.sendMessage("§7原版此情况【静默无输出】:execute 的 @s 解析不到就直接中止,不报任何错。");
cs.sendMessage("§7改法三选一:");
cs.sendMessage("§7 · 想对该玩家执行 → 直接写玩家名,如 §e/execute at Steve run say 123");
cs.sendMessage("§7 · 想对全体在线玩家 → 换成 §e@a");
cs.sendMessage("§7 · 想让某个玩家自己执行 → 用 §e/execute as <玩家> run §7加原命令");
getLogger().info("[命令] 控制台 execute 链含 @s 被拦: " + raw + "(@s 出现在 as 之前)");
return;
}
// 纯文本广播命令:@s 是要发出去的字面文字,不是选择器,不拦
if (head.equals("say") || head.equals("me") || head.equals("teammsg")
|| head.equals("tm") || head.equals("help") || head.equals("?")) {
@@ -4158,7 +4198,22 @@ public class Main extends JavaPlugin
body = body.trim();
if (body.isEmpty()) return;
if (body.charAt(0) == '/') body = body.substring(1).trim();
if (body.isEmpty()) return;
if (body.isEmpty()) {
// ★ 问题2(2026-10-14):只输入了 "/"(去头后为空)—— 原实现直接
// return 且不压制,Paper 原生 "Unknown or incomplete command" 就此
// 溜给运维(生产日志实证)。现压掉原生报错 + 中文提示正确写法:
// 控制台直接输入指令名(无需斜杠),除非指令本身就以斜杠开头。
suppressUnknown(e);
if (sender instanceof Player) {
sender.sendMessage("§c§l[命令未执行] §7你只输入了 §f/ §7,后面没有指令名,"
+ "请输入完整指令,如 §f/list§7、§f/tpa Steve");
} else {
sender.sendMessage("§c§l[命令未执行] §7控制台直接输入指令名即可(无需斜杠),"
+ "如 §flist§7、§fsay 你好");
}
getLogger().info("[无感切换] 单斜杠空指令已拦下并中文化: " + e.getCommandLine());
return;
}
// ---- 0) 诊断日志:确认本处理器是否真的被触发 ----
boolean isPlayer = sender instanceof Player;
@@ -149,14 +149,47 @@ public class OpWhiteListManager implements Listener {
// ★ 2026-10-13 问题3:初始延迟原来写死 300 tick=15s,从插件启动时刻起算,
// 于是轮询点整体漂移固定偏移(实际 6/21/36/51 秒,期望 0/15/30/45 秒)。
// 改为对齐到下一个 15 秒墙钟边界后,再按 15s 周期跑(同 startShCfgReloadTask 范式)。
long now15 = System.currentTimeMillis();
long next15 = (now15 / 15000L + 1) * 15000L;
long delay15Ticks = Math.max(1L, (next15 - now15) / 50L);
Bukkit.getScheduler().runTaskTimer(plugin, this::checkAndReload,
delay15Ticks, 300L);
long delayMs = millisToNextQuarterPoint();
Bukkit.getScheduler().runTaskTimer(plugin, this::onQuarterPoint,
(long) delayMs / 50L, 18000L);
// ★ 2026-10-14 问题4(第十三批复发根因):上一版只对齐了【初始延迟】,
// 固定 period(300/18000 tick)在 TPS≠20 时逐轮累积漂移,运行几小时后
// 轮询点又飘离 0/15/30/45 秒整点。改为墙钟自对齐、每轮执行完重排的
// runTaskLater 调度(见 scheduleAligned),无论 TPS 多少都精确落点。
scheduleAligned(this::checkAndReload, 15000L, "15s热重载");
scheduleAligned(this::onQuarterPoint, 900000L, "整点扫描");
}
/**
* ★ 2026-10-14 问题4:墙钟自对齐、每轮重排的调度器(替代固定 period 的 runTaskTimer)。
* 每轮任务跑完后按【执行时刻的墙钟】重新计算下一个 periodMs 边界再 runTaskLater,
* 漂移不累积 —— TPS 高低、卡顿都影响不了落点;任务异常也不打断重排(finally)。
*
* @param task 每轮要跑的任务
* @param periodMs 墙钟周期毫秒(15000=对齐 :00/:15/:30/:45 秒;900000=对齐 :00/:15/:30/:45 分)
* @param tag 日志标签(异常时打印用)
*/
private void scheduleAligned(Runnable task, long periodMs, String tag) {
Runnable self = new Runnable() {
@Override
public void run() {
if (!plugin.isEnabled()) {
return; // 插件已禁用,不再重排
}
try {
task.run();
} catch (Throwable t) {
plugin.getLogger().warning("[OP白名单] " + tag + "任务异常(下轮照常): "
+ t.getMessage());
} finally {
long now = System.currentTimeMillis();
long next = (now / periodMs + 1) * periodMs;
long delayTicks = Math.max(1L, (next - now + 49L) / 50L);
Bukkit.getScheduler().runTaskLater(plugin, this, delayTicks);
}
}
};
long now = System.currentTimeMillis();
long next = (now / periodMs + 1) * periodMs;
long delayTicks = Math.max(1L, (next - now + 49L) / 50L);
Bukkit.getScheduler().runTaskLater(plugin, self, delayTicks);
}
// ================= 配置读取 =================
@@ -385,8 +418,15 @@ public class OpWhiteListManager implements Listener {
if (!dir.exists()) {
dir.mkdirs();
}
// 原版 ops.json 不存在或为空 = 没有内容可丢;已有历史备份则原样保留(绝不用空文件覆盖)
if (!src.exists() || src.length() == 0) {
// 原版 ops.json 不存在 / 空白 / 只剩 "[]" 空壳 = 没有内容可丢;
// 一律保留已有历史备份(★ 2026-10-14 问题4:fail() 清空后 ops.json 是
// 2 字节的 "[]",旧判空 length()==0 挡不住 → REPLACE_EXISTING 会用空壳
// 覆盖掉唯一一份好备份,管理员就只能手动逐个恢复 OP)
if (!src.exists() || isDegenerateOps(src)) {
if (dst.exists() && dst.length() > 0) {
plugin.getLogger().info("[OP白名单] 原版 ops.json 实质为空(空壳),"
+ "已有历史备份原样保留: OP白名单/" + NATIVE_OPS_BACKUP);
}
return true;
}
Files.copy(src.toPath(), dst.toPath(),
@@ -406,6 +446,19 @@ public class OpWhiteListManager implements Listener {
}
}
/** ops.json 是否实质为空(不存在 / 空白 / 只有 "[]" 空壳)——空壳绝不允许覆盖历史备份 */
private static boolean isDegenerateOps(File f) {
try {
if (!f.exists() || f.length() == 0) {
return true;
}
String s = new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8).trim();
return s.isEmpty() || s.equals("[]");
} catch (Exception e) {
return false; // 读不了 → 当作有内容,走正常备份流程(失败则按铁律不卸载)
}
}
/**
* ★ 从留底备份复原原版 OP(返回复原到的玩家名,供 adoptToWhitelist 写回白名单)。
*
@@ -740,6 +793,12 @@ public class OpWhiteListManager implements Listener {
+ String.join(", ", justRestored));
}
// 1) 非法持有扫描:持 OP 但不在白名单 → 撤销 + 报警
// ★ 问题4 铁律(2026-10-14 用户原话):「先备份原生 ops.json 到插件自己的
// json 文件夹下面来,再卸载删除」。本循环是 fail() 之外唯一的自动撤销入口
// (配置自愈后的全量接管也走这里)—— 此前该路径一次备份都没做过,
// 用户没提前备份就只能手动逐个恢复。备份失败 → 数据第一优先级,
// 本轮中止 apply(不撤也不授),下轮自动重试。
boolean opsBackedUp = false;
for (OfflinePlayer op : new ArrayList<>(Bukkit.getOperators())) {
String n = op.getName();
if (n == null || n.isEmpty() || !op.isOp()) {
@@ -751,10 +810,19 @@ public class OpWhiteListManager implements Listener {
if (justRestored.contains(n.toLowerCase(Locale.ROOT))) {
continue;
}
if (!opsBackedUp) {
if (!backupNativeOps()) {
plugin.getLogger().warning("[OP白名单] 撤销前备份原版 ops.json 失败 → "
+ "本轮【跳过全部撤销】(数据第一优先级),下轮自动重试");
return;
}
opsBackedUp = true;
}
setOpByName(n, false);
warnIllegal(n, now, describeIllegal(n));
// 卸任提示当事人(在线直发,离线暂存)
notifyOpChange(n, false, "自动撤销(不在白名单:" + describeIllegal(n) + ")");
// ★ 问题5(2026-10-14):玩家侧最小化信息披露 —— 只说结果,
// 不点名白名单/判定细节(细节只进控制台 warnIllegal 日志)
notifyAutoRevoked(n);
}
// 2) 白名单内玩家补授(已是 OP 的跳过)
// ★ 2026-10-08 现场修复:原实现只遍历【在线】玩家,导致离线授权人
@@ -1177,7 +1245,8 @@ public class OpWhiteListManager implements Listener {
saveConfig();
if (isOpNow(name)) {
setOpByName(name, false);
notifyOpChange(name, false, "临时OP被扣减至到期,已撤销");
// ★ 问题6:旧文案与模板拼成病句「你的 临时OP被扣减至到期,已撤销 已撤销」
notifyAutoRevoked(name);
}
sender.sendMessage("§a扣减后 " + name + " 的临时OP已到期,条目已移除,OP 已撤销");
} else {
@@ -1201,7 +1270,9 @@ public class OpWhiteListManager implements Listener {
boolean wasOp = isOpNow(name);
setOpByName(name, false);
if (wasOp) {
notifyOpChange(name, false, inTimed ? "临时OP被移除" : "长期OP被移除");
// ★ 问题6(2026-10-14):旧文案「你的 长期OP被移除 已撤销」是病句,
// 改走固定文案「管理员已撤销你的OP」(最小化信息披露)
notifyAutoRevoked(name);
}
lastWarn.remove(name.toLowerCase(Locale.ROOT));
if (!inPerm && !inTimed && !wasOp) {
@@ -1282,10 +1353,22 @@ public class OpWhiteListManager implements Listener {
/** 提示当事人 OP 上任/卸任。在线直发;离线暂存 pendingMsg,上线时补发。 */
public void notifyOpChange(String name, boolean isGrant, String what) {
if (name == null || name.isEmpty()) return;
Player p = Bukkit.getPlayerExact(name);
String msg = isGrant
? "§a[OP白名单] 你已获得 §f" + what + "§a。"
: "§c[OP白名单] 你的 §f" + what + " §c已撤销,OP 权限已收回。";
notifyMsg(name, msg);
}
/** ★ 问题5(2026-10-14):自动撤销的玩家侧固定文案 —— 最小化信息披露,
* 只说结果「管理员已撤销你的OP」;白名单/判定细节只留控制台 warnIllegal 日志。 */
public void notifyAutoRevoked(String name) {
if (name == null || name.isEmpty()) return;
notifyMsg(name, "§c[OP白名单] §f管理员已撤销你的OP");
}
/** 提示投递:在线直发,离线暂存(每人最多 2 条,上线由 flushPendingMsgs 投递) */
private void notifyMsg(String name, String msg) {
Player p = Bukkit.getPlayerExact(name);
if (p != null) {
p.sendMessage(msg);
} else {
+44 -6
View File
@@ -84,6 +84,9 @@ public class ShopManager implements Listener {
new HashMap<>();
// 批量总件数(用于进度提示)
private final Map<UUID, Integer> publishQueueTotal = new HashMap<>();
// ★ 问题3(2026-10-14):批量成功/跳过计数(与 publishQueue 同生命周期)
private final Map<UUID, Integer> publishQueueOk = new HashMap<>();
private final Map<UUID, Integer> publishQueueSkip = new HashMap<>();
// ===== 编辑模式(2026-10-11 任务1):UUID → 正在编辑的商品ID =====
private final Map<UUID, Long> editListingId = new HashMap<>();
// ★ 会话令牌(2026-10-11 第五批):进入新会话/新步骤时 +1,
@@ -4753,6 +4756,8 @@ public class ShopManager implements Listener {
}
publishQueue.put(uuid, items);
publishQueueTotal.put(uuid, items.size());
publishQueueOk.put(uuid, 0);
publishQueueSkip.put(uuid, 0);
p.sendMessage("§6§l[二手商店] §e开始批量上架,共 §f" + items.size() + " §e件");
beginNextPublishItem(p, uuid);
}
@@ -4761,9 +4766,7 @@ public class ShopManager implements Listener {
private void beginNextPublishItem(Player p, UUID uuid) {
java.util.LinkedList<ItemStack> q = publishQueue.get(uuid);
if (q == null || q.isEmpty()) {
publishQueue.remove(uuid);
publishQueueTotal.remove(uuid);
p.sendMessage("§a§l[二手商店] §f全部商品已上架完成");
finishPublishQueue(p, uuid);
if (p.isOnline()) openSecondHandShop(p, 0);
return;
}
@@ -4786,6 +4789,7 @@ public class ShopManager implements Listener {
returnItem(p, it);
p.sendMessage("§c[二手商店] 跳过不可上架物品 §e"
+ ItemNameResolver.toChinese(it.getType()) + " §c(" + blocked + "),已退还");
publishQueueSkip.merge(uuid, 1, Integer::sum); // ★ 问题3:跳过计数
beginNextPublishItem(p, uuid);
return;
}
@@ -4862,6 +4866,8 @@ public class ShopManager implements Listener {
ItemStack back = pendingPublishItem.remove(uuid);
java.util.LinkedList<ItemStack> q = publishQueue.remove(uuid);
publishQueueTotal.remove(uuid);
publishQueueOk.remove(uuid);
publishQueueSkip.remove(uuid);
publishListening.remove(uuid);
publishStep.remove(uuid);
pendingPublishDesc.remove(uuid);
@@ -4906,6 +4912,8 @@ public class ShopManager implements Listener {
// ★ 批量队列剩余一并退还(2026-10-11 任务2)
java.util.LinkedList<ItemStack> q = publishQueue.remove(uuid);
publishQueueTotal.remove(uuid);
publishQueueOk.remove(uuid);
publishQueueSkip.remove(uuid);
int extra = 0;
if (q != null) {
extra = q.size();
@@ -5074,6 +5082,7 @@ public class ShopManager implements Listener {
}
secondHandListings.add(l);
saveSecondHand();
publishQueueOk.merge(uuid, 1, Integer::sum); // ★ 问题3:成功计数
// ★ 上架成功立刻触发一次增量热重载,把配置数据对齐(2026-10-11 问题4 例外)
reloadSecondHandConfigIncremental("上架触发");
p.sendMessage("§a§l[二手商店] §f上架成功: §e" + ItemNameResolver.toChinese(item.getType())
@@ -5095,13 +5104,40 @@ public class ShopManager implements Listener {
p.sendMessage("§7进度: 还剩 §f" + q.size() + " §7件待定价");
beginNextPublishItem(p, uuid);
} else {
publishQueue.remove(uuid);
publishQueueTotal.remove(uuid);
p.sendMessage("§a§l[二手商店] §f全部商品已上架完成");
finishPublishQueue(p, uuid);
if (p.isOnline()) openSecondHandShop(p, 0);
}
}
/**
* ★ 批量上架统一收尾(2026-10-14 问题3):按成功/跳过计数给出准确语义。
* 此前两处收尾无条件打「全部商品已上架完成」——物品被退回(禁售跳过/全禁)
* 时也这么说,语义误导。计数与 publishQueue 同生命周期:
* startBatchPublish 置 0 → 成功 add(l) 时 ok++、禁售跳过 skip++ →
* abortPublish / 超时 / 退出一并清理。
*/
private void finishPublishQueue(Player p, UUID uuid) {
Integer ok = publishQueueOk.remove(uuid);
Integer skip = publishQueueSkip.remove(uuid);
publishQueue.remove(uuid);
publishQueueTotal.remove(uuid);
if (ok == null && skip == null) {
// 防御:计数缺失(理论不可达)→ 退回旧文案
p.sendMessage("§a§l[二手商店] §f全部商品已上架完成");
return;
}
int okN = ok == null ? 0 : ok;
int skipN = skip == null ? 0 : skip;
if (skipN == 0) {
p.sendMessage("§a§l[二手商店] §f全部商品已上架完成(共 " + okN + " 件)");
} else if (okN == 0) {
p.sendMessage("§c§l[二手商店] §f" + skipN + " 件物品全部不可上架、已退还,本次未上架任何商品");
} else {
p.sendMessage("§a§l[二手商店] §f批量上架结束:成功上架 §a" + okN
+ " §f件、跳过退还 §c" + skipN + " §f件");
}
}
/** 取消上架:清全部暂存状态并退还物品(批量模式下连同队列剩余一并退还) */
private void abortPublish(Player p, UUID uuid, ItemStack item) {
publishListening.remove(uuid);
@@ -5113,6 +5149,8 @@ public class ShopManager implements Listener {
// ★ 批量上架(2026-10-11 任务2):中止时把队列里未定价的物品全部退还
java.util.LinkedList<ItemStack> q = publishQueue.remove(uuid);
publishQueueTotal.remove(uuid);
publishQueueOk.remove(uuid);
publishQueueSkip.remove(uuid);
if (q != null && !q.isEmpty()) {
int back = q.size() + (item != null ? 1 : 0);
for (ItemStack it : q) returnItem(p, it);
@@ -41,4 +41,5 @@
| 100债券 | 买家出 | 5‰ | 100 | 101 | 100(原价)×5‰(手续费)=0.5(4舍5入),100(原价)+1(手续费取整)=101 |
- 改完多久生效: 自动增量重载,平时安安静静,有人下单了,发布新商品了自动拉起增量重载,有变更就对齐。每个整点(每分钟的第0秒、15秒、30秒、45秒)自动拉起增量热重载,有变更就对齐,没有则跳过<br>
- 默认理由最大支持8个,第9号位为自定义下架理由,故默认内置理由仅支持8个
- 默认理由最大支持8个,第9号位为返回。<br>
- 谁是管理员这一栏填单格式:`player+example`→指定example为管理员,`op`→指定全服OP为管理员,`tag`→指定插件的tag玩家是管理员