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)"); + + /** + * 把 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(" ", "");