diff --git a/src/main/java/Sdf1_login/BanBroadcastManager.java b/src/main/java/Sdf1_login/BanBroadcastManager.java index a2e8079..a991ddf 100644 --- a/src/main/java/Sdf1_login/BanBroadcastManager.java +++ b/src/main/java/Sdf1_login/BanBroadcastManager.java @@ -6,6 +6,9 @@ import org.bukkit.BanEntry; import org.bukkit.BanList; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.scheduler.BukkitRunnable; @@ -42,7 +45,7 @@ import java.util.regex.Pattern; * 表格值:由近到远 / 由远到近 / 0。 * 间隔或模式为 0 → 关闭播报。 */ -public class BanBroadcastManager { +public class BanBroadcastManager implements Listener { private final Main plugin; private final Random rnd = new Random(); @@ -60,11 +63,24 @@ public class BanBroadcastManager { private boolean startCutoffAuto = true; // 起点未配置 → 自动取插件数据目录创建时间(=插件安装时间) private long autoCutoffMs = -1; // 自动起点缓存(-1=未取) private boolean jsonSyntaxOk = false; // json 文本语法是否有效(区分「语法坏」与「语法好但值空」) + private volatile boolean timerRunning = false; // 已有待触发的排程(防重复拉起) + private volatile boolean stoppedByEmpty = false; // 全员下线导致的停机(等玩家上线恢复) + private static BanBroadcastManager instance; // 单插件单实例,供 WebManager 取播报起点 public BanBroadcastManager(Main plugin) { this.plugin = plugin; + instance = this; } + /** 单例访问(WebManager 推送封禁时取播报起点) */ + public static BanBroadcastManager get() { return instance; } + + /** + * 播报起点(毫秒),0=不过滤。与游戏内播报同一套语义(配置的播报起点 / 自动=插件数据目录创建时间), + * 供 PHP 端封禁公示过滤「插件上服务器之前的历史封禁」——两端同源,不各配一套。 + */ + public long getStartCutoff() { return resolveStartCutoff(); } + // ==================== 生命周期 ==================== public void start() { @@ -74,6 +90,7 @@ public class BanBroadcastManager { ensureConfigExists(); reloadConfig(); scheduleNext(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); long cut0 = resolveStartCutoff(); plugin.getLogger().info("[封禁播报] 已启动,模式=" + modeName() + " 间隔=" + minInterval + "~" + maxInterval + "s 每页" + pageSize + "条" @@ -82,6 +99,20 @@ public class BanBroadcastManager { public void shutdown() { /* BukkitRunnable 随插件卸载自动取消 */ } + /** + * ★ 第十九批:定时器生命周期 —— 停止条件只有两个:全员下线、服务器关机/插件卸载。 + * 配置为「关闭」不停定时器:只静默本轮,下一轮醒来重读配置, + * 改回开启(或把间隔从 0 恢复)即自动续播,无需重启。 + * 全员下线时 scheduleNext 的空服分支停掉排程,第一个玩家上线由此重新拉起。 + * (start/runTaskLater/PlayerJoinEvent 都在主线程,状态无并发问题) + */ + @EventHandler + public void onPlayerJoin(PlayerJoinEvent e) { + if (!stoppedByEmpty || timerRunning) return; + stoppedByEmpty = false; + scheduleNext(); + } + // ==================== 配置:自愈 + 双模式解析 ==================== /** 找 ban/ 目录下任意 *.json 配置文件 */ @@ -358,7 +389,8 @@ public class BanBroadcastManager { int delaySec; if (mode == 0) { // 播报关闭时仍每 60 秒自检一次:配置文件被删则自愈补发、 - // 运行中把 0 改回正常值可自动恢复播报(无需重启) + // 运行中把 0 改回正常值可自动恢复播报(无需重启)。 + // ★ 关闭 ≠ 停机:定时器只在全员下线/关服时才停(第十九批) delaySec = 60; } else { // ★ 每次在[最小间隔,最大间隔]之间随机取一个秒数,不是固定值 @@ -367,14 +399,17 @@ public class BanBroadcastManager { lastDelaySec = delaySec; } long delayTicks = Math.max(20L, delaySec * 20L); + timerRunning = true; new BukkitRunnable() { @Override public void run() { + timerRunning = false; if (!plugin.isEnabled()) return; - // ★ 服务器没人就闭嘴:无人在线时不播报、不重载、不自愈、不打任何日志, - // 直接排下一轮(没人时播报和日志打出来也没人看) + // ★ 服务器没人就停机:全员下线是定时器的两个停止条件之一(另一个是关服), + // 不播报、不重载、不自愈、不打任何日志,直接停排程 —— + // 第一个玩家上线时 onPlayerJoin 重新拉起(配置为关闭同样只静默本轮) if (Bukkit.getOnlinePlayers().isEmpty()) { - scheduleNext(); + stoppedByEmpty = true; return; } // ★ 懒重载:每次播报窗口重读配置 —— 开关决定这次报/不报, diff --git a/src/main/java/Sdf1_login/WebManager.java b/src/main/java/Sdf1_login/WebManager.java index 4d44ba2..c0653eb 100644 --- a/src/main/java/Sdf1_login/WebManager.java +++ b/src/main/java/Sdf1_login/WebManager.java @@ -321,6 +321,8 @@ public class WebManager { private String lastBondBalanceHash = ""; private String lastLandDataHash = ""; private String lastBansHash = ""; + private boolean bansCreatedPushed = false; // ★ 第十九批:进程内是否已把 created/起点推给PHP过一次 + private long lastPushedCutoff = -1; // 上次推送时的播报起点(变了就再强制推一轮) private String lastAdminsHash = ""; private String lastUserRegistrationHash = ""; @@ -5564,10 +5566,13 @@ public class WebManager { String reason = entry.getReason() != null ? entry.getReason() : ""; String source = entry.getSource() != null ? entry.getSource() : ""; long expireDate = entry.getExpiration() != null ? entry.getExpiration().getTime() : 0; + // ★ 第十九批:封禁创建时间(毫秒)一并下发 —— PHP 公示按它过滤插件上架前的历史封禁 + long createdDate = entry.getCreated() != null ? entry.getCreated().getTime() : 0; sb.append("{\"type\":\"name\",\"target\":\"").append(escapeJson(target)) .append("\",\"reason\":\"").append(escapeJson(reason)) .append("\",\"source\":\"").append(escapeJson(source)) - .append("\",\"expire\":").append(expireDate).append("}"); + .append("\",\"expire\":").append(expireDate) + .append(",\"created\":").append(createdDate).append("}"); hashBuilder.append("n:").append(target).append(":").append(reason).append(":").append(expireDate).append("|"); } @@ -5581,10 +5586,12 @@ public class WebManager { String reason = entry.getReason() != null ? entry.getReason() : ""; String source = entry.getSource() != null ? entry.getSource() : ""; long expireDate = entry.getExpiration() != null ? entry.getExpiration().getTime() : 0; + long createdDate = entry.getCreated() != null ? entry.getCreated().getTime() : 0; sb.append("{\"type\":\"ip\",\"target\":\"").append(escapeJson(target)) .append("\",\"reason\":\"").append(escapeJson(reason)) .append("\",\"source\":\"").append(escapeJson(source)) - .append("\",\"expire\":").append(expireDate).append("}"); + .append("\",\"expire\":").append(expireDate) + .append(",\"created\":").append(createdDate).append("}"); hashBuilder.append("i:").append(target).append(":").append(reason).append(":").append(expireDate).append("|"); } @@ -5598,14 +5605,20 @@ public class WebManager { Set> ib0 = (Set>) (Set) ipBanList.getEntries(); for (org.bukkit.BanEntry e0 : ib0) banRows.add(banRowOf(e0, "ip")); AlignResult arAlign = alignGate("bans", banRows); - if (arAlign != null && !arAlign.needPush()) return; // 零发送 + // ★ 第十九批:created/ban_cutoff(PHP公示过滤历史封禁用)不进对账列,两端行一致时 + // alignGate 判「零发送」会把它们永远拦住 —— 进程内首轮 & 起点配置变化时强制整包推一轮; + // 只绕过「零发送」,「本轮只收不发」保持原样(pull 后立即整包推会先把 PHP 多的行抹掉)。 + BanBroadcastManager bbCut = BanBroadcastManager.get(); + long cutoffNow = bbCut != null ? bbCut.getStartCutoff() : 0L; + boolean forceCreatedPush = !bansCreatedPushed || cutoffNow != lastPushedCutoff; + if (arAlign != null && !arAlign.needPush() && !forceCreatedPush) return; // 零发送 if (arAlign != null && arAlign.pulled > 0) return; // 本轮只收不发 sb.append("]"); String currentHash = hashBuilder.toString(); // ★ 对账明说要推时本地 hash 不得拦截(同 admins,2026-10-06 灾备修复) boolean alignNeedsPushBans = arAlign != null && arAlign.needPush(); - if (!alignNeedsPushBans && currentHash.equals(lastBansHash)) { + if (!alignNeedsPushBans && !forceCreatedPush && currentHash.equals(lastBansHash)) { plugin.getLogger().fine("[防护-sync] 封禁名单无变化,跳过"); return; } @@ -5632,12 +5645,16 @@ public class WebManager { body.put("secret", secretKey); body.put("bans", sb.toString()); body.put("ips", ipsb.toString()); + // ★ 第十九批:连同「播报起点」一起推给PHP(get_ban_board 用它过滤插件安装前的历史封禁) + body.put("ban_cutoff", String.valueOf(cutoffNow)); String resp = httpPost("api/sync.php?action=sync_bans", mapToJson(body)); plugin.getLogger().info("[防护-sync] 封禁名单同步: " + (nameEntries.size() + ipEntries.size()) + "条(IP黑名单 " + ipOnlyEntries.size() + "个) → " + resp); // ★ 关键修复:hash 成功后才提交(防 404 时把 hash 写脏 → 永久漏推,2026-10-06) if (resp != null && resp.contains("\"success\":true")) { lastBansHash = currentHash; + bansCreatedPushed = true; + lastPushedCutoff = cutoffNow; } else { plugin.getLogger().warning("[防护-sync] 封禁名单同步未确认成功,下轮重试(hash 不提交)"); }