修复维护模式两个线上问题:
Check Source / check (push) Successful in 6s
Auto Release / build (push) Failing after 1m5s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s

1) JSON 消息内的字面 <br>/<br/>/</br> 不被摘除(JSON 分支绕过了换行预处理)
   -> deserialize 前先替换成 JSON 转义换行,标签不再漏给玩家
2) [白名单] 段标题被删(只剩「# ===== 白名单段 =====」注释)时玩家名被静默丢弃,
   白名单读成 0 人且 OP可绕过=flase 拼错也无提示 -> 所有人被拦
   -> 注释式段标题也识别 + 段外无键值行按玩家名兜底收录
   -> 条目清洗(零宽/全角空格/行内注释/列表前缀)+ 布尔拼写告警
   -> 维护开启时打印白名单条目与控制段键,便于对账

测试:T3 真实源码抽取 46/46(含用户真实白名单.txt 解析)、maint 33/0、whitelist 27/0
This commit is contained in:
youpaishidifu
2026-09-28 22:50:43 +08:00
parent fb297b34fb
commit 61d53d7689
+185 -40
View File
@@ -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<String, String> ctl = new LinkedHashMap<>();
Set<String> 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<String, String> ctl = pc.ctl;
Set<String> 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 内的 &lt;br&gt; 变体(<br> <br/> <br /> </br>,大小写不敏感) */
private static final Pattern BR_IN_JSON_PAT = Pattern.compile("(?i)</?br\\s*/?>");
/**
* 把 JSON 文本里的字面 &lt;br&gt; 换成 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 等);字面 &lt;br&gt; 会先换成 JSON 转义换行
* 2) 否则处理 &lt;br&gt; 与字面 \n 换行、&amp; 颜色码与 § 颜色码
*/
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 字符串里的字面 <br>/<br/> 同样要变成换行,否则标签会原样显示给玩家。
// 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<String, String> ctl;
final Set<String> wl;
final boolean sawWhiteHeader;
ParsedConfig(Map<String, String> ctl, Set<String> wl, boolean sawWhiteHeader) {
this.ctl = ctl;
this.wl = wl;
this.sawWhiteHeader = sawWhiteHeader;
}
}
/**
* 解析配置文本。三层兜底,防止管理员把自己关在门外:
* 1) 段标题判断在注释跳过之前——「# ===== 白名单段 =====」这种注释形式的标题也认
* 2) 段外的无键值行(既没有 = 也没有 :)按玩家名收录——标题行被删时靠它救
* 3) 条目先清洗零宽字符 / 行内注释 / 列表前缀再收录,避免肉眼看着对却匹配不上
*/
static ParsedConfig parseConfig(String text, Logger log) {
Map<String, String> ctl = new LinkedHashMap<>();
Set<String> 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<String> 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<String> 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(" ", "");