From 61d53d7689947b44d7525af2c5e015ad48631b60 Mon Sep 17 00:00:00 2001
From: youpaishidifu <1@ypshidifu.cn>
Date: Mon, 28 Sep 2026 22:50:43 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BB=B4=E6=8A=A4=E6=A8=A1?=
=?UTF-8?q?=E5=BC=8F=E4=B8=A4=E4=B8=AA=E7=BA=BF=E4=B8=8A=E9=97=AE=E9=A2=98?=
=?UTF-8?q?=EF=BC=9A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1) JSON 消息内的字面
/
/ 不被摘除(JSON 分支绕过了换行预处理)
-> deserialize 前先替换成 JSON 转义换行,标签不再漏给玩家
2) [白名单] 段标题被删(只剩「# ===== 白名单段 =====」注释)时玩家名被静默丢弃,
白名单读成 0 人且 OP可绕过=flase 拼错也无提示 -> 所有人被拦
-> 注释式段标题也识别 + 段外无键值行按玩家名兜底收录
-> 条目清洗(零宽/全角空格/行内注释/列表前缀)+ 布尔拼写告警
-> 维护开启时打印白名单条目与控制段键,便于对账
测试:T3 真实源码抽取 46/46(含用户真实白名单.txt 解析)、maint 33/0、whitelist 27/0
---
.../java/Sdf1_login/MaintenanceManager.java | 225 ++++++++++++++----
1 file changed, 185 insertions(+), 40 deletions(-)
diff --git a/src/main/java/Sdf1_login/MaintenanceManager.java b/src/main/java/Sdf1_login/MaintenanceManager.java
index 359fd9c..1f48b90 100644
--- a/src/main/java/Sdf1_login/MaintenanceManager.java
+++ b/src/main/java/Sdf1_login/MaintenanceManager.java
@@ -31,6 +31,7 @@ import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
+import java.util.logging.Logger;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -140,6 +141,7 @@ public class MaintenanceManager implements Listener {
"#",
"# [控制段] 用「键 = 值」写服务器开关、时间与提示语",
"# [白名单段] 严格一行一个玩家名(一行只写一个,不支持逗号/空格分隔)",
+ "# (标题行漏写也能兜底收录,但请尽量保留 [白名单] 标题)",
"#",
"# 可用键:",
"# 启用 = true / false",
@@ -201,46 +203,31 @@ public class MaintenanceManager implements Listener {
return;
}
- Map ctl = new LinkedHashMap<>();
- Set wl = new LinkedHashSet<>();
- boolean inWhite = false;
- for (String rawLine : text.split("\n")) {
- String line = rawLine.trim();
- if (line.isEmpty()) continue;
- if (line.startsWith("#") || line.startsWith("//")) continue;
- if (isWhitelistHeader(line)) {
- inWhite = true;
- continue;
- }
- if (inWhite) {
- // ★ 严格按「一行一个玩家名」:整行就是一个条目,不再按逗号/顿号/空白拆分。
- // 含分隔符的行说明格式写错了,直接告警便于发现,而不是静默拆成多个错误条目。
- if (line.indexOf(',') >= 0 || line.indexOf('\uFF0C') >= 0 || line.indexOf('\u3001') >= 0
- || line.indexOf(';') >= 0 || line.indexOf('\uFF1B') >= 0 || line.indexOf(' ') >= 0
- || line.indexOf('\u3000') >= 0 || line.indexOf('\t') >= 0) {
- plugin.getLogger().warning("[维护模式] 白名单条目不符合「一行一个玩家名」,已按整行收录: " + line);
- }
- wl.add(line);
- } else {
- int eq = line.indexOf('=');
- if (eq < 0) eq = line.indexOf(':');
- if (eq < 0) continue;
- String k = normKey(line.substring(0, eq));
- if (k.isEmpty()) continue;
- ctl.put(k, line.substring(eq + 1).trim());
- }
- }
+ // 行扫描抽成 static parseConfig(含段标题丢失兜底 / 条目清洗),便于单测
+ ParsedConfig pc = parseConfig(text, plugin.getLogger());
+ Map ctl = pc.ctl;
+ Set wl = pc.wl;
boolean wasActive = isActive();
boolean wasEnabled = this.enabled;
- boolean en = toBool(pick(ctl,
- "启用", "开启", "开关", "维护", "enable", "enabled", "maintenance", "on"), false);
+ String enRaw = pick(ctl,
+ "启用", "开启", "开关", "维护", "enable", "enabled", "maintenance", "on");
+ boolean en = toBool(enRaw, false);
Long st = parseTime(pick(ctl, "开始时间", "开始", "起始时间", "start", "starttime", "begintime"));
Long enT = parseTime(pick(ctl, "结束时间", "结束", "截止时间", "end", "endtime"));
Long dur = parseDuration(pick(ctl, "时长", "持续时间", "持续", "duration", "hours", "小时"));
- boolean op = toBool(pick(ctl, "OP可绕过", "op可绕过", "OP绕过", "管理员绕过",
- "opbypass", "op_bypass", "op", "bypass"), true);
+ String opRaw = pick(ctl, "OP可绕过", "op可绕过", "OP绕过", "管理员绕过",
+ "opbypass", "op_bypass", "op", "bypass");
+ boolean op = toBool(opRaw, true);
+ if (enRaw != null && !isBoolWord(enRaw)) {
+ plugin.getLogger().warning("[维护模式] 「启用」的值不是可识别的布尔词: ["
+ + enRaw + "],按 false 处理(请写 true / false)");
+ }
+ if (opRaw != null && !isBoolWord(opRaw)) {
+ plugin.getLogger().warning("[维护模式] 「OP可绕过」的值不是可识别的布尔词: ["
+ + opRaw + "],按 false 处理(请写 true / false)");
+ }
String msg = pick(ctl, "消息", "提示", "维护消息", "踢出消息", "message", "msg", "kickmessage");
if (msg == null || msg.isEmpty()) msg = DEFAULT_MESSAGE;
@@ -292,9 +279,15 @@ public class MaintenanceManager implements Listener {
} else if (wasActive) {
plugin.getLogger().info("[维护模式] 维护已结束");
}
- if (nowActive && whitelistLower.isEmpty() && !opBypass) {
- plugin.getLogger().warning("[维护模式] ⚠ 维护已开启,但白名单为空且 OP 不可绕过:"
- + "所有玩家都会被拦在外面!请检查 [白名单] 段标题与条目写法");
+ // ★ 直接把读到的条目与控制段键打出来,线上「配了却进不去」时一眼能对账
+ if (nowActive && !whitelistLower.isEmpty()) {
+ plugin.getLogger().info("[维护模式] 白名单条目: [" + summarize(whitelistLower) + "]");
+ }
+ if (nowActive && whitelistLower.isEmpty()) {
+ plugin.getLogger().warning("[维护模式] ⚠ 维护已开启但白名单为 0 人!段标题[白名单]识别="
+ + pc.sawWhiteHeader
+ + (opBypass ? ",OP 仍可绕过" : ",且 OP 不可绕过:所有玩家都会被拦在外面")
+ + ";控制段读到的键: " + ctl.keySet() + ",请检查 [白名单] 段标题与条目写法");
}
}
}
@@ -329,7 +322,8 @@ public class MaintenanceManager implements Listener {
// ★ 无感化拦截:在异步预登录阶段直接断开,客户端只看到自定义提示,
// 服务端不会为他分配世界/区块,也不产生多余的网络往返
e.disallow(AsyncPlayerPreLoginEvent.Result.KICK_BANNED, buildMessage(name));
- plugin.getLogger().info("[维护模式] 已拦截未在白名单的玩家: " + name);
+ plugin.getLogger().info("[维护模式] 已拦截未在白名单的玩家: " + name
+ + "(当前白名单 " + whitelistLower.size() + " 人)");
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -390,10 +384,21 @@ public class MaintenanceManager implements Listener {
return (f == '{' && l == '}') || (f == '[' && l == ']');
}
+ /** JSON 内的 <br> 变体(
,大小写不敏感) */
+ private static final Pattern BR_IN_JSON_PAT = Pattern.compile("(?i)?br\\s*/?>");
+
+ /**
+ * 把 JSON 文本里的字面 <br> 换成 JSON 转义换行(反斜杠 + n 两字符)。
+ * 必须是两字符序列:真实换行在 JSON 字符串里非法,写进去整段就解析失败了。
+ */
+ private static String replaceBrInJson(String json) {
+ return BR_IN_JSON_PAT.matcher(json).replaceAll("\\\\n");
+ }
+
/**
* 消息格式化:
* 1) 整段是 JSON(对象 {...} 或顶层数组 [...],后者是 tellraw 常见写法)
- * → 按文本组件解析(支持 text/color/extra 等)
+ * → 按文本组件解析(支持 text/color/extra 等);字面 <br> 会先换成 JSON 转义换行
* 2) 否则处理 <br> 与字面 \n 换行、& 颜色码与 § 颜色码
*/
private static Component format(String msg) {
@@ -401,7 +406,10 @@ public class MaintenanceManager implements Listener {
String trimmed = msg.trim();
if (looksLikeJson(trimmed)) {
try {
- return GsonComponentSerializer.gson().deserialize(trimmed);
+ // ★ JSON 字符串里的字面
/
同样要变成换行,否则标签会原样显示给玩家。
+ // Gson 只认 JSON 转义(反斜杠 + n),所以替换成两字符序列,而不是真实换行
+ // (真实换行在 JSON 字符串里是非法字符,会让整段解析失败退回纯文本)。
+ return GsonComponentSerializer.gson().deserialize(replaceBrInJson(trimmed));
} catch (Throwable t) {
// 不是合法 JSON,退回普通文本处理
}
@@ -448,7 +456,13 @@ public class MaintenanceManager implements Listener {
/** 段落头:[白名单] / 【白名单】 / 白名单 / ===== 白名单段 ===== / 玩家白名单 / [WHITELIST](含 = 的行一律不是段落头) */
private static boolean isWhitelistHeader(String line) {
String s = line.trim();
- if (s.isEmpty() || s.startsWith("#") || s.startsWith("//")) return false;
+ if (s.isEmpty()) return false;
+ // ★ 注释形式的段标题也算:用户常把「[白名单]」行删掉,只留下「# ===== 白名单段 =====」这条注释。
+ // 先剥掉 # /// 注释前缀再判;键值对类注释(含 =)仍被下面的硬门槛挡住,不会误判成段落头。
+ while (s.startsWith("#") || s.startsWith("//")) {
+ s = s.substring(s.startsWith("//") ? 2 : 1).trim();
+ }
+ if (s.isEmpty()) return false;
// ★ 硬门槛:先剥掉首尾装饰符号(=====、----、::: 等),再看中间是否还剩「=」。
// 剩下的 = 说明这是控制段的键值对(如「挂机白名单 = xxx」),绝不是段落头;
// 只有装饰等号的 =====白名单段===== 剥完就干净了,不会被误杀。
@@ -467,6 +481,137 @@ public class MaintenanceManager implements Listener {
|| low.endsWith("白名单") || low.endsWith("whitelist") || low.endsWith("allowlist");
}
+ // ==================== 配置文本解析(static,便于单测) ====================
+
+ /** parseConfig 的结果:控制段键值对 + 白名单条目 + 是否见过 [白名单] 段标题 */
+ static final class ParsedConfig {
+ final Map ctl;
+ final Set wl;
+ final boolean sawWhiteHeader;
+
+ ParsedConfig(Map ctl, Set wl, boolean sawWhiteHeader) {
+ this.ctl = ctl;
+ this.wl = wl;
+ this.sawWhiteHeader = sawWhiteHeader;
+ }
+ }
+
+ /**
+ * 解析配置文本。三层兜底,防止管理员把自己关在门外:
+ * 1) 段标题判断在注释跳过之前——「# ===== 白名单段 =====」这种注释形式的标题也认
+ * 2) 段外的无键值行(既没有 = 也没有 :)按玩家名收录——标题行被删时靠它救
+ * 3) 条目先清洗零宽字符 / 行内注释 / 列表前缀再收录,避免肉眼看着对却匹配不上
+ */
+ static ParsedConfig parseConfig(String text, Logger log) {
+ Map ctl = new LinkedHashMap<>();
+ Set wl = new LinkedHashSet<>();
+ boolean inWhite = false;
+ boolean sawHeader = false;
+ for (String rawLine : text.split("\n")) {
+ String line = rawLine.trim();
+ if (line.isEmpty()) continue;
+ if (isWhitelistHeader(line)) {
+ inWhite = true;
+ sawHeader = true;
+ continue;
+ }
+ if (line.startsWith("#") || line.startsWith("//")) continue;
+ if (inWhite) {
+ addWhitelistEntry(wl, line, log, null);
+ } else {
+ int eq = line.indexOf('=');
+ if (eq < 0) eq = line.indexOf(':');
+ if (eq < 0) {
+ addWhitelistEntry(wl, line, log,
+ "该行不在 [白名单] 段内也不是键值对,已按玩家名兜底收录");
+ continue;
+ }
+ String k = normKey(line.substring(0, eq));
+ if (k.isEmpty()) continue;
+ ctl.put(k, line.substring(eq + 1).trim());
+ }
+ }
+ if (!sawHeader && !wl.isEmpty()) {
+ warn(log, "未识别到 [白名单] 段标题,已按「无键值=玩家名」兜底收录 "
+ + wl.size() + " 个条目");
+ }
+ return new ParsedConfig(ctl, wl, sawHeader);
+ }
+
+ /** 清洗并收录一条白名单条目(note 为附加告警说明,可为 null) */
+ private static void addWhitelistEntry(Set wl, String raw, Logger log, String note) {
+ String s = stripInvisible(raw);
+ if (!s.equals(raw)) {
+ warn(log, "白名单条目含不可见字符(零宽/全角空格/不间断空格),已清洗: ["
+ + raw + "] -> [" + s + "]");
+ }
+ int c = indexOfInlineComment(s);
+ if (c >= 0) {
+ warn(log, "白名单条目的行内注释已忽略: " + s.substring(c).trim());
+ s = s.substring(0, c).trim();
+ }
+ // 列表装饰前缀:- > * · •(合法 MC 名不会以这些字符开头)
+ String noDecor = s.replaceFirst("^[-*>\\u00b7\\u2022]\\s+", "");
+ if (!noDecor.equals(s)) {
+ warn(log, "白名单条目前缀符号已忽略: [" + s + "] -> [" + noDecor + "]");
+ s = noDecor;
+ }
+ s = s.trim();
+ if (s.isEmpty()) return;
+ if (note != null) warn(log, note + ": " + raw);
+ // 仍坚持「一行一个玩家名」:整行收录,但格式不规范必须能被看见
+ if (s.indexOf(',') >= 0 || s.indexOf('\uFF0C') >= 0 || s.indexOf('\u3001') >= 0
+ || s.indexOf(';') >= 0 || s.indexOf('\uFF1B') >= 0 || s.indexOf(' ') >= 0
+ || s.indexOf('\u3000') >= 0 || s.indexOf('\t') >= 0) {
+ warn(log, "白名单条目不符合「一行一个玩家名」,已按整行收录: " + s);
+ }
+ wl.add(s);
+ }
+
+ /** 去掉肉眼看不见却会让匹配失败的字符:零宽/BOM/不间断空格/全角空格 */
+ private static String stripInvisible(String s) {
+ if (s == null) return null;
+ return s.replaceAll("[\\u200B-\\u200D\\u2060\\uFEFF\\u00A0\\u3000]", "");
+ }
+
+ /** 行内注释起点(# / # / //),返回 -1 表示没有 */
+ private static int indexOfInlineComment(String s) {
+ int h = s.indexOf('#');
+ if (h < 0) h = s.indexOf('\uFF03');
+ if (h < 0) h = s.indexOf("//");
+ return h <= 0 ? -1 : h;
+ }
+
+ private static void warn(Logger log, String msg) {
+ if (log != null) log.warning("[维护模式] " + msg);
+ }
+
+ /** 布尔词识别(与 toBool 口径一致),用于把 flase / ture 这类拼写错误提示出来 */
+ private static boolean isBoolWord(String v) {
+ if (v == null) return false;
+ String s = v.trim().toLowerCase(Locale.ROOT);
+ if (s.isEmpty()) return false;
+ return s.equals("true") || s.equals("yes") || s.equals("y") || s.equals("on")
+ || s.equals("1") || s.equals("是") || s.equals("开") || s.equals("开启") || s.equals("启用")
+ || s.equals("false") || s.equals("no") || s.equals("n") || s.equals("off")
+ || s.equals("0") || s.equals("否") || s.equals("关") || s.equals("关闭") || s.equals("停用");
+ }
+
+ /** 白名单条目摘要(最多 20 个),日志里直接对账用 */
+ private static String summarize(Set names) {
+ StringBuilder sb = new StringBuilder();
+ int i = 0;
+ for (String n : names) {
+ if (i == 20) {
+ sb.append(", …共").append(names.size()).append("人");
+ return sb.toString();
+ }
+ if (i++ > 0) sb.append(", ");
+ sb.append(n);
+ }
+ return sb.toString();
+ }
+
private static String normKey(String k) {
if (k == null) return "";
return k.trim().toLowerCase(Locale.ROOT).replace(" ", "").replace("_", "").replace(" ", "");