修复维护模式两个线上问题:
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:
@@ -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 内的 <br> 变体(<br> <br/> <br /> </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 字符串里的字面 <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(" ", "");
|
||||
|
||||
Reference in New Issue
Block a user