第十六批:雪球菜单SN校验+游戏内轮询封禁播报(BanBroadcastManager)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m30s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s

This commit is contained in:
youpaishidifu
2026-10-14 18:04:48 +08:00
parent 1c34c9618b
commit 8d73722b6a
3 changed files with 461 additions and 0 deletions
@@ -0,0 +1,381 @@
package Sdf1_login;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.bukkit.BanEntry;
import org.bukkit.BanList;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.scheduler.BukkitRunnable;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* ★ 第十六批:游戏内轮询封禁播报(耻辱台)
*
* 配置文件(plugins/Sdf1_login/ban/ 目录):
* - *.json 直接作为配置文件;
* - 规则配置.md 优先解 ```json 代码块,解不出再解 markdown 表格;
* - 解码全失败 → 备份原文件 + 释放一份新的。
* 配置丢失 → 自动补发(saveResource 自愈)。
*
* 键值:轮播最低间隔(秒) / 轮播最大间隔(秒) / 播报模式
* 播报模式:true=由近到远、false=由远到近、0=关闭;
* 表格值:由近到远 / 由远到近 / 0。
* 间隔或模式为 0 → 关闭播报。
*/
public class BanBroadcastManager {
private final Main plugin;
private final Random rnd = new Random();
private File banDir;
private File mdFile;
private File jsonFile; // *.json 配置(存在则优先)
private int minInterval = 60;
private int maxInterval = 180;
private int mode = 1; // 0=关闭 1=由近到远 2=由远到近
private int pageSize = 9; // 每轮播报条数(耻辱台一页)
public BanBroadcastManager(Main plugin) {
this.plugin = plugin;
}
// ==================== 生命周期 ====================
public void start() {
banDir = new File(plugin.getDataFolder(), "ban");
mdFile = new File(banDir, "规则配置.md");
jsonFile = findJsonConfig();
ensureConfigExists();
reloadConfig();
scheduleNext();
plugin.getLogger().info("[封禁播报] 已启动,模式=" + modeName()
+ " 间隔=" + minInterval + "~" + maxInterval + "s 每页" + pageSize + "条");
}
public void shutdown() { /* BukkitRunnable 随插件卸载自动取消 */ }
// ==================== 配置:自愈 + 双模式解析 ====================
/** 找 ban/ 目录下任意 *.json 配置文件 */
private File findJsonConfig() {
File[] fs = banDir == null ? null : banDir.listFiles(
(d, n) -> n.toLowerCase(Locale.ROOT).endsWith(".json"));
return (fs != null && fs.length > 0) ? fs[0] : null;
}
/** 配置文件丢失就补一份(自愈) */
private void ensureConfigExists() {
try {
if (jsonFile != null) {
if (!banDir.exists()) banDir.mkdirs();
if (!jsonFile.exists()) {
plugin.saveResource("ban/" + jsonFile.getName(), false);
log("配置文件丢失,已自愈补发: " + jsonFile.getName());
}
return;
}
if (!mdFile.exists()) {
banDir.mkdirs();
plugin.saveResource("ban/规则配置.md", false);
log("配置文件丢失,已自愈补发: 规则配置.md");
}
} catch (Exception e) {
warn("自愈补发失败: " + e.getMessage());
}
}
/** 重新加载配置(json 优先 → md json块 → md 表格 → 备份重置) */
public void reloadConfig() {
try {
if (jsonFile == null) jsonFile = findJsonConfig();
if (jsonFile != null && jsonFile.exists()) {
String txt = readFile(jsonFile);
if (parseJsonText(txt)) { log("已加载 json 配置: " + jsonFile.getName()); return; }
backupAndReset(jsonFile, jsonFile.getName());
return;
}
if (!mdFile.exists()) { ensureConfigExists(); }
String md = readFile(mdFile);
if (md != null) {
String jsonBlock = extractJsonBlock(md);
if (jsonBlock != null && parseJsonText(jsonBlock)) {
log("已加载 md 内嵌 json 配置: 规则配置.md"); return;
}
if (parseTableText(md)) { log("已加载 md 表格配置: 规则配置.md"); return; }
}
// 全部解码失败 → 备份原文件 + 释放一份新的
backupAndReset(mdFile, "规则配置.md");
} catch (Exception e) {
warn("reloadConfig 异常: " + e.getMessage());
}
}
/** 从 md 提取 ```json 代码块内容(不含围栏) */
private String extractJsonBlock(String md) {
Pattern p = Pattern.compile("```json\\s*\\n(.*?)\\n\\s*```", Pattern.DOTALL);
Matcher m = p.matcher(md);
return m.find() ? m.group(1).trim() : null;
}
/**
* 解析 json 文本(键:最小间隔/最大间隔/播报模式)。
* 出厂 规则配置.md 的 json 块值为空字符串而表格有真值(文档语义:二者选一填),
* 因此仅当 json 里出现「非空间隔值」才认 json 生效;
* 只有播报模式、没有任何间隔键的纯 json 也认生效(间隔走默认)。
* @return true = json 配置生效;false = 视为未配置,调用方回退表格
*/
private boolean parseJsonText(String txt) {
try {
JsonObject o = JsonParser.parseString(txt).getAsJsonObject();
boolean hasIntervalKey = o.has("最小间隔") || o.has("最大间隔")
|| o.has("轮播最低间隔") || o.has("轮播最大间隔");
boolean anyInterval = false;
Integer v;
if ((v = jsonInt(o, "最小间隔")) != null) { minInterval = v; anyInterval = true; }
if ((v = jsonInt(o, "最大间隔")) != null) { maxInterval = v; anyInterval = true; }
if ((v = jsonInt(o, "轮播最低间隔")) != null) { minInterval = v; anyInterval = true; }
if ((v = jsonInt(o, "轮播最大间隔")) != null) { maxInterval = v; anyInterval = true; }
boolean modePresent = false;
if (o.has("播报模式") && !o.get("播报模式").isJsonNull()) {
mode = parseMode(o.get("播报模式").getAsString());
modePresent = true;
}
if (anyInterval) { normalize(); return true; }
// 没有可用间隔值:纯「只有播报模式且无间隔键」的 json 也算生效
if (modePresent && !hasIntervalKey) { normalize(); return true; }
return false;
} catch (Exception e) {
return false;
}
}
/** json 取间隔值:键存在且非空才返回(空串/null → null,留给表格或默认值) */
private Integer jsonInt(JsonObject o, String key) {
try {
if (!o.has(key) || o.get(key).isJsonNull()) return null;
String s = o.get(key).getAsString();
if (s == null || s.trim().isEmpty()) return null;
int r = toSec(s);
return r >= 0 ? r : null;
} catch (Exception e) {
return null;
}
}
/** 解析 md 表格(| 名字 | 说明 | 参数值 |) */
private boolean parseTableText(String md) {
boolean any = false;
for (String line : md.split("\n")) {
String t = line.trim();
if (!t.startsWith("|") || t.startsWith("| ---") || t.startsWith("|---")) continue;
String[] cells = t.split("\\|");
if (cells.length < 4) continue;
String key = cells[1].trim();
String val = cells[cells.length - 2].trim();
if (key.equals("轮播最低间隔")) { minInterval = toSec(val); any = true; }
else if (key.equals("轮播最大间隔")) { maxInterval = toSec(val); any = true; }
else if (key.equals("播报模式")) { mode = parseMode(val); any = true; }
}
if (any) normalize();
return any;
}
/** 播报模式文本/布尔/数字 → 0/1/2 */
private int parseMode(String s) {
if (s == null) return 1;
String v = s.trim();
if (v.equals("0") || v.equalsIgnoreCase("false")) return 2; // false=由远到近
if (v.equals("1") || v.equalsIgnoreCase("true")) return 1; // true=由近到远
if (v.contains("近到远")) return 1; // 由近到远
if (v.contains("远到近")) return 2; // 由远到近
return 1;
}
/** 秒数文本 → int(容错"60s"/"60秒"/"60") */
private int toSec(String s) {
String v = s == null ? "" : s.trim().replaceAll("[^0-9]", "");
if (v.isEmpty()) return 0;
try { return Integer.parseInt(v); } catch (Exception e) { return 0; }
}
/** 归一化:模式为0 或 最大最小间隔都为0 → 关闭;最大<最低 → 补齐(对齐配置说明语义) */
private void normalize() {
if (minInterval < 0) minInterval = 0;
if (maxInterval < 0) maxInterval = 0;
if (maxInterval > 0 && minInterval > maxInterval) maxInterval = minInterval;
if (mode != 0 && mode != 1 && mode != 2) mode = 1;
if (mode == 0 || (minInterval == 0 && maxInterval == 0)) mode = 0;
}
// ==================== 调度 ====================
/** 排下一次窗口期:随机 [min,max] 秒后播报,再自我reschedule */
private void scheduleNext() {
int delaySec;
if (mode == 0) {
// 播报关闭时仍每 60 秒自检一次:配置文件被删则自愈补发、
// 运行中把 0 改回正常值可自动恢复播报(无需重启)
delaySec = 60;
} else {
int span = maxInterval - minInterval;
delaySec = minInterval + (span > 0 ? rnd.nextInt(span + 1) : 0);
}
long delayTicks = Math.max(20L, delaySec * 20L);
new BukkitRunnable() {
@Override
public void run() {
if (!plugin.isEnabled()) return;
// ★ 自愈 + 热重载:每次窗口期前检查配置存在性并重读
ensureConfigExists();
reloadConfig();
if (mode != 0) {
try { broadcastOnce(); }
catch (Exception e) { warn("播报异常: " + e.getMessage()); }
}
scheduleNext();
}
}.runTaskLater(plugin, delayTicks);
}
// ==================== 播报 ====================
/** 取当前生效封禁条目(未过期),按创建时间排序 */
@SuppressWarnings("unchecked")
private List<BanEntry<?>> collectBans() {
List<BanEntry<?>> list = new ArrayList<>();
try {
BanList nb = Bukkit.getBanList(BanList.Type.NAME);
// getEntries() 实际返回 Set,按 Collection 中转强转(防 ClassCastException)
for (BanEntry<?> e : (Collection<BanEntry<?>>) (Collection<?>) nb.getEntries()) {
Date exp = e.getExpiration();
if (exp != null && exp.getTime() <= System.currentTimeMillis()) continue;
list.add(e);
}
} catch (Exception e) {
warn("读取封禁名单失败: " + e.getMessage());
}
return list;
}
/** 轮播当前页并广播给全服 */
private void broadcastOnce() {
List<BanEntry<?>> bans = collectBans();
if (bans.isEmpty()) { log("当前无生效封禁,跳过本轮"); return; }
// 由近到远:最新封禁在前;由远到近:最早封禁在前
bans.sort((a, b) -> {
long ta = a.getCreated() != null ? a.getCreated().getTime() : 0L;
long tb = b.getCreated() != null ? b.getCreated().getTime() : 0L;
return mode == 1 ? Long.compare(tb, ta) : Long.compare(ta, tb);
});
int total = bans.size();
int start = cursor % total;
int end = Math.min(start + pageSize, total);
List<BanEntry<?>> page = bans.subList(start, end);
cursor = end % total;
String head = "§c§l⚠ §6违规封禁公示 §7(共§f" + total + "§7人, 第§f"
+ (start / pageSize + 1) + "§7/" + ((total + pageSize - 1) / pageSize) + "§7页)";
Bukkit.broadcastMessage(head);
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm");
for (BanEntry<?> e : page) {
String target = e.getTarget();
String reason = e.getReason() != null && !e.getReason().isEmpty()
? e.getReason() : "违反服务器规则";
String when;
Date created = e.getCreated();
when = created != null ? df.format(created) : "未知时间";
Date exp = e.getExpiration();
String expStr = exp != null ? ("§7至§f" + df.format(exp)) : "§c永久";
Bukkit.broadcastMessage("§c☠ §f" + target + " §7- " + reason
+ " §8| §7封于§f" + when + " §8| " + expStr);
}
Bukkit.broadcastMessage("§7§o请各位玩家引以为戒,遵守服务器规则。");
log("本轮已播报 " + page.size() + "/" + total + " 条封禁");
}
/** 轮播游标(下一页起点) */
private int cursor = 0;
// ==================== 管理员命令 ====================
/** 手动重载配置并汇报当前生效值 */
public void report(CommandSender to) {
reloadConfig();
to.sendMessage("§a[封禁播报] §7模式=§f" + modeName()
+ " §7间隔=§f" + minInterval + "~" + maxInterval + "s"
+ " §7每页=§f" + pageSize + "条"
+ (mode == 0 ? " §c(已关闭)" : ""));
}
private String modeName() {
return mode == 0 ? "关闭" : (mode == 1 ? "由近到远" : "由远到近");
}
// ==================== 工具 ====================
private String readFile(File f) {
if (f == null || !f.exists()) return null;
try (BufferedReader br = new BufferedReader(new InputStreamReader(
new FileInputStream(f), StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line).append('\n');
return sb.toString();
} catch (Exception e) {
warn("读取失败 " + f.getName() + ": " + e.getMessage());
return null;
}
}
/** 备份原配置文件 + 释放一份新的(自愈) */
private void backupAndReset(File f, String resourceName) {
try {
if (f != null && f.exists()) {
String bak = f.getAbsolutePath() + ".bak." + System.currentTimeMillis();
copyFile(f, new File(bak));
warn("配置解码失败,已备份原文件: " + new File(bak).getName());
}
// 先删旧文件再从 jar 释放新的
if (f != null && f.exists()) f.delete();
plugin.saveResource("ban/" + resourceName, false);
log("已释放一份新配置: " + resourceName);
} catch (Exception e) {
warn("备份/重置失败: " + e.getMessage());
}
}
private void copyFile(File src, File dst) throws IOException {
try (FileInputStream in = new FileInputStream(src);
FileOutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
}
}
private void log(String s) { plugin.getLogger().info("[封禁播报] " + s); }
private void warn(String s) { plugin.getLogger().warning("[封禁播报] " + s); }
}
+54
View File
@@ -557,6 +557,9 @@ public class Main extends JavaPlugin
// 全程无 ERROR 只有 SSL 断路器 WARN —— 日志抓不到卡点栈,靠它下次复现
// 直接把主线程栈打进日志定位)
new MainThreadWatchdog(this).start();
// ★ 第十六批:游戏内轮询封禁播报(耻辱台)
// 配置 ban/规则配置.md 或 ban/*.json,丢失自愈补发、解码失败备份重置
new BanBroadcastManager(this).start();
afk.startCheck();
startLoginReminder();
startTimeoutCheck();
@@ -3388,6 +3391,51 @@ public class Main extends JavaPlugin
}
}
/**
* ★ 第十六批:雪球菜单 SN 校验(仅雪球开 GUI 路径调用)
* 规则:
* - 无 SN(历史雪球)→ 放行;
* - SN 未登记 → 拒绝(数据异常/伪造);
* - 状态 illegal(锁定)→ 拒绝;
* - 登记持有者 ≠ 实际持有者 → 拒绝;
* - active/lost 且持有者一致 → 放行。
* 校验异常时放行(防 DB 抖动误伤),只记日志。
* @return true=放行可打开主 GUI;false=已提示并拒绝
*/
private boolean verifyMenuSnowballSn(Player p) {
try {
if (snManager == null) return true;
ItemStack it = p.getInventory().getItemInMainHand();
if (!isMenuSnowball(it)) it = p.getInventory().getItemInOffHand();
String sn = snManager.readSn(it);
if (sn == null || sn.isEmpty()) return true; // 历史雪球无 SN,放行
java.util.Map<String, Object> row = snManager.getSn(sn);
if (row == null) {
p.sendMessage("§c§l[菜单] §f菜单雪球校验失败:SN 未登记,请联系管理员");
getLogger().info("[菜单] 雪球SN校验拒绝: " + p.getName() + " SN=" + sn + " 未登记");
return false;
}
String st = row.get("status") == null ? "" : String.valueOf(row.get("status"));
if (SnManager.ST_ILLEGAL.equals(st)) {
p.sendMessage("§c§l[菜单] §f该菜单雪球已被锁定,无法打开菜单");
getLogger().info("[菜单] 雪球SN校验拒绝: " + p.getName() + " SN=" + sn + " 已锁定");
return false;
}
String owner = row.get("owner") == null ? "" : String.valueOf(row.get("owner"));
String me = p.getName();
// 持有者精确匹配(离线模式大小写不同=不同玩家)
if (!owner.isEmpty() && !owner.equals(me)) {
p.sendMessage("§c§l[菜单] §f该菜单雪球不属于你,无法打开菜单");
getLogger().info("[菜单] 雪球SN校验拒绝: " + me + " 持有 " + owner + " 的 SN=" + sn);
return false;
}
return true;
} catch (Exception e) {
getLogger().warning("[菜单] 雪球SN校验异常(放行): " + e.getClass().getSimpleName() + ": " + e.getMessage());
return true;
}
}
private boolean isMenuSnowball(ItemStack item) {
if (item == null
|| item.getType()
@@ -3544,6 +3592,12 @@ public class Main extends JavaPlugin
if (isMenuSnowball(hand)
|| isMenuSnowball(offhand)) {
e.setCancelled(true);
// ★ 第十六批:雪球菜单打开 GUI 前校验 SN
// 被锁定 / SN登记持有者与实际持有者不符 → 拒绝打开主 GUI
// 主指令 /sdf1_login 不走这里,不受影响
if (!verifyMenuSnowballSn(p)) {
return;
}
gui.openMain(p);
return;
}
+26
View File
@@ -0,0 +1,26 @@
## ----配置区开始----
| 名字 | 功能说明 | 参数值 |
| --- | --- | --- |
| 轮播最低间隔 | 最短是多少秒一次(建议阈值60,太低很吵) | 60 |
| 轮播最大间隔 | 最长多少秒一次(建议阈值300以内,太长起不到效果) | 180 |
| 播报模式 | 是由远到近(即从第一个封禁的玩家开始报到最后一个),还是又近到远(从最新一个被封禁的玩家播报到第一个被封禁的玩家)。此项填0或最大最小间隔都为0表示关闭播报 | 由近到远 |
```json
{
"最小间隔":"",
"最大间隔":"",
"播报模式":true,
"解释说明":{
"播报模式":"true表示开启播报又近到远,false表示开启播报由远到近。0为关闭",
"最小间隔":"最短是多少秒一次(建议阈值60,太低很吵)",
"最大间隔":"最长多少秒一次(建议阈值300以内,太长起不到效果)"
}
}
```
## ----配置区域结束----
## 功能说明
- 此功能为服务器打造一个不可拆解的耻辱台,展示所有被封禁的玩家。json代码块的参数值和md表格是2选1,你可以删了表格,留json则表示用json作为配置值,也可以删了json用表格。小白用户推荐使用md,因为最简单,专业人员推荐使用json,因为最清晰,没有注释(注释是另起一行,不在配置块)。<br>
- 同时兼容.json直接作为配置文件