fix: PVP观察者改纯命令切换+双向免伤、清空队列联动PHP清理+回写lambda化、封禁IP推送Web黑名单
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s

This commit is contained in:
youpaishidifu
2026-09-27 21:24:00 +08:00
parent d8ff4b0c92
commit 2bcc449bc4
4 changed files with 286 additions and 174 deletions
+14 -3
View File
@@ -780,7 +780,8 @@ public class Main extends JavaPlugin
"tempban", "arena",
"join", "leave",
"exit", "test",
"equip", "adjust"};
"equip", "adjust",
"see"};
for (String s : sub) {
if (s.startsWith(
args[0]
@@ -814,6 +815,16 @@ public class Main extends JavaPlugin
list.add(rn);
}
}
} else if (sub.equals("join")
|| sub.equals("arena")
|| sub.equals("leave")
|| sub.equals("exit")) {
// ★ /pvp join see、/pvp leave see —— 观赛模式
if ("see".startsWith(
args[1]
.toLowerCase())) {
list.add("see");
}
}
}
return list;
@@ -6205,8 +6216,8 @@ public class Main extends JavaPlugin
sender.sendMessage("§cWeb通信管理器未初始化");
return true;
}
webManager.clearQueue();
sender.sendMessage("§a已清空DB队列中未回应的web同步任务");
String clearSummary = webManager.clearQueue();
sender.sendMessage("§a已清空DB队列: §f" + clearSummary);
return true;
}
// /menu 开关雪球菜单
+89 -84
View File
@@ -2777,8 +2777,6 @@ public class PVPArenaManager implements Listener {
// ==================== PVP 观赛(/pvp see 观察者模式)====================
/** 观赛雪球 lore 标记(与菜单雪球标签 sdf1_menu 区分,避免被误判为菜单物品) */
private static final String SPECTATE_TAG = "PVP观赛|";
/** 玩家名 → 进入观察者前的游戏模式(离场/重连时还原,避免把观察者模式带到主世界) */
private final Map<String, GameMode> spectatorBackups = new ConcurrentHashMap<>();
@@ -2791,40 +2789,74 @@ public class PVPArenaManager implements Listener {
/**
* /pvp see —— PVP 观赛模式(仅 PVP 世界生效)
* ① 已在观察者 → 切回普通生存模式
* ② 否则:回收 PVP 装备(★ 不发还玩家自己的装备,自己的装备仍在备份里、离场时统一还原)
* → 发放 2 个不同的雪球 → 自动进入 MC 原版观察者模式
*
* 三种调用形式:
* /pvp see → 切换(在观察者⇄普通模式之间来回切,mode=null)
* /pvp join see → 强制进入观察者(mode="enter")
* /pvp leave see → 强制退出观察者(mode="exit")
*
* ★ v2 设计变更:原版【观察者模式下物品栏会被客户端自动隐藏替换】,
* 雪球发了也看不见、无法右键,故已彻底移除雪球方案,只保留命令切换。
* 观察者不能伤害玩家,玩家也不能伤害观察者(见 onSpectatorCombat)。
*/
public void handleSeeCommand(Player p) {
handleSeeCommand(p, null);
}
/**
* @param mode null=切换 "enter"=强制进观察者 "exit"=强制退观察者
*/
public void handleSeeCommand(Player p, String mode) {
boolean already = p.getGameMode() == GameMode.SPECTATOR;
// ★ 退出观察者在任何世界都放行(防止玩家被带离PVP世界后卡在观察者回不来)
if ("exit".equals(mode)) {
if (!already) {
p.sendMessage("§e§l[PVP观赛] §f你当前不在观察者模式");
return;
}
exitSpectator(p);
return;
}
if (!isPVPWorld(p.getWorld())) {
p.sendMessage("§c§l[PVP观赛] §f仅在PVP世界可用(/pvp join 进入竞技场后再使用)");
return;
}
if (p.getGameMode() == GameMode.SPECTATOR) {
exitSpectator(p);
if ("enter".equals(mode)) {
if (already) {
p.sendMessage("§e§l[PVP观赛] §f你已经在观察者模式了");
return;
}
enterSpectator(p);
return;
}
enterSpectator(p);
// mode == null → 切换
if (already) {
exitSpectator(p);
} else {
enterSpectator(p);
}
}
/** 进入观察者观赛:回收PVP装备(不退自己装备) → 发2个雪球 → 进入原版观察者 */
/** 进入观察者观赛:回收PVP装备(不退自己装备) → 记录原模式 → 进入原版观察者 */
private void enterSpectator(Player p) {
if (p.getGameMode() == GameMode.SPECTATOR) return;
// 1) 回收PVP专属装备;★ 玩家自己的装备【不发还】,仍留在备份中,离场时统一还原
回收PVPEquipment(p);
// 2) 发放2个不同的雪球(必须在进入观察者之前发放:观察者收不到 /give,会掉在地上)
giveSpectatorSnowballs(p);
// 3) 记录原模式并进入 MC 原版观察者
// 2) 记录原模式并进入 MC 原版观察者
// ★ 必须【先记再切】:观察者模式下 addItem 会掉地上,故不再发放任何物品
spectatorBackups.put(p.getName(), p.getGameMode());
p.setGameMode(GameMode.SPECTATOR);
p.sendMessage("§a§l[PVP观赛] §f已进入观察者模式,可穿墙自由观赛");
p.sendMessage("§7 §b右键「观赛·观察者视角」§7→ 原版观察者(穿墙飞行)");
p.sendMessage("§7 §a右键「观赛·生存视角」§7→ 普通生存(落到地面观赛)");
p.sendMessage("§7 也可再次输入 §f/pvp see §7在两种模式间切换");
p.sendMessage("§7 §f/pvp see §7→ 切换回普通模式");
p.sendMessage("§7 §f/pvp leave see §7→ 退出观察者");
p.sendMessage("§7 旁观状态下你与他人互不伤害,可放心观战");
p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
plugin.getLogger().info("[PVP] 玩家 " + p.getName() + " 进入观赛观察者模式");
}
@@ -2874,81 +2906,54 @@ public class PVPArenaManager implements Listener {
}
}
/** 发放2个不同的雪球:观察者视角 / 生存视角(右键触发切换) */
private void giveSpectatorSnowballs(Player p) {
ItemStack spec = makeSpectateSnowball("§b观赛·观察者视角",
"§7右键 → 切换到 MC 原版观察者模式", "观察者");
ItemStack surv = makeSpectateSnowball("§a观赛·生存视角",
"§7右键 → 切换到普通生存模式(落地观赛)", "生存");
Map<Integer, ItemStack> left = p.getInventory().addItem(spec, surv);
if (left != null && !left.isEmpty()) {
for (ItemStack it : left.values()) {
p.getWorld().dropItemNaturally(p.getLocation(), it);
}
}
}
private ItemStack makeSpectateSnowball(String name, String desc, String key) {
ItemStack it = new ItemStack(Material.SNOWBALL);
ItemMeta im = it.getItemMeta();
if (im != null) {
im.setDisplayName(name);
im.setLore(Arrays.asList(desc, "§8[" + SPECTATE_TAG + key + "]"));
it.setItemMeta(im);
}
return it;
}
/** 判定雪球类型:0=非观赛雪球 1=观察者视角 2=生存视角 */
private int spectateSnowballType(ItemStack item) {
if (item == null || item.getType() != Material.SNOWBALL) return 0;
ItemMeta im = item.getItemMeta();
if (im == null || !im.hasLore()) return 0;
List<String> lore = im.getLore();
if (lore == null) return 0;
for (String line : lore) {
if (line == null || !line.contains(SPECTATE_TAG)) continue;
if (line.contains("观察者")) return 1;
if (line.contains("生存")) return 2;
}
return 0;
}
/**
* 观赛雪球:右键切换 观察者 ⇄ 生存(仅PVP世界生效)。
* ★ 必须 setCancelled(true),否则雪球会被当成投掷物消耗掉。
* ★ 原版限制:观察者模式下客户端只在【对着方块右键】时才发包,对空气右键不发包;
* 若当时视线没对准方块,可改用 /pvp see 切换(两种方式等价)。
* ★ 观察者战斗保护(仅 PVP 世界):
* ① 观察者不能伤害任何人(旁观状态下客户端本不发攻击包,此处兜底拦截投掷物/残留判定)
* ② 任何人不能伤害观察者(含玩家、生物、箭、TNT、摔落等一切伤害来源)
* 观察者视角进入的EntityDamageByEntityEvent中 damager 为观察者本人;
* 保护观察者则需同时拦掉 e.getEntity() 是观察者的情形。
*/
@EventHandler
public void onSpectateSnowball(PlayerInteractEvent e) {
Action a = e.getAction();
if (a != Action.RIGHT_CLICK_AIR && a != Action.RIGHT_CLICK_BLOCK) return;
if (e.getHand() == org.bukkit.inventory.EquipmentSlot.OFF_HAND) return;
Player p = e.getPlayer();
if (!isPVPWorld(p.getWorld())) return;
@EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
public void onSpectatorCombat(org.bukkit.event.entity.EntityDamageByEntityEvent e) {
if (!(e.getEntity() instanceof Player)) return;
Player victim = (Player) e.getEntity();
int type = spectateSnowballType(e.getItem());
if (type == 0) return;
e.setCancelled(true); // 阻止雪球被投掷消耗
if (type == 1) { // 观察者视角
if (p.getGameMode() == GameMode.SPECTATOR) {
p.sendMessage("§e[PVP观赛] 你已经在观察者模式了");
return;
// ② 别人打观察者 → 直接取消
if (victim.getGameMode() == GameMode.SPECTATOR) {
if (isPVPWorld(victim.getWorld())) {
e.setCancelled(true);
}
enterSpectator(p);
} else { // 生存视角
if (p.getGameMode() != GameMode.SPECTATOR) {
p.sendMessage("§e[PVP观赛] 你已经在生存模式了");
return;
}
exitSpectator(p);
return;
}
// ① 观察者打别人 → 取消(观察者投出的雪球/药水等弹射物伤害)
org.bukkit.entity.Entity damager = e.getDamager();
Player attacker = null;
if (damager instanceof Player) {
attacker = (Player) damager;
} else if (damager instanceof org.bukkit.entity.Projectile) {
org.bukkit.projectiles.ProjectileSource src =
((org.bukkit.entity.Projectile) damager).getShooter();
if (src instanceof Player) attacker = (Player) src;
}
if (attacker != null
&& attacker.getGameMode() == GameMode.SPECTATOR
&& isPVPWorld(attacker.getWorld())) {
e.setCancelled(true);
}
}
// ==================== 公共查询 ====================
/** 观察者被其他实体(非EntityDamageByEntity路径)直接伤害时的兜底:取消 PVP 世界内对观察者的伤害 */
@EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
public void onSpectatorHurt(org.bukkit.event.entity.EntityDamageEvent e) {
if (!(e.getEntity() instanceof Player)) return;
Player victim = (Player) e.getEntity();
if (victim.getGameMode() != GameMode.SPECTATOR) return;
if (!isPVPWorld(victim.getWorld())) return;
e.setCancelled(true);
}
// ==================== 公共查询 ====================
public boolean isInPVPArena(String playerName) {
return inPVPArena.contains(playerName);
+14 -2
View File
@@ -1123,6 +1123,11 @@ public class PVPManager implements Listener {
p, args);
case "join":
case "arena":
// ★ /pvp join see → 直接进入观察者观赛模式
if (args.length >= 2 && args[1].equalsIgnoreCase("see")) {
plugin.getPVPArenaManager().handleSeeCommand(p, "enter");
return true;
}
plugin.getPVPArenaManager().joinArena(p);
return true;
case "equip":
@@ -1134,11 +1139,16 @@ public class PVPManager implements Listener {
plugin.getPVPArenaManager().openEquipmentSelection(p);
return true;
case "see":
// PVP观赛:进入/退出观察者模式(仅PVP世界生效,右键雪球可随时切换)
// PVP观赛:/pvp see=切换观察者⇄普通(仅PVP世界,权限对所有人开放)
plugin.getPVPArenaManager().handleSeeCommand(p);
return true;
case "leave":
case "exit":
// ★ /pvp leave see → 退出观察者模式
if (args.length >= 2 && args[1].equalsIgnoreCase("see")) {
plugin.getPVPArenaManager().handleSeeCommand(p, "exit");
return true;
}
plugin.leavePVP(p);
return true;
default:
@@ -1146,7 +1156,9 @@ public class PVPManager implements Listener {
p.sendMessage("§7——— PVP指令 ———");
p.sendMessage("§e/pvp join §7进入PVP竞技场");
p.sendMessage("§e/pvp equip §7调整装备(食物/盾牌/弓弩)");
p.sendMessage("§e/pvp see §7观赛:进入/退出观察者模式(仅PVP世界)");
p.sendMessage("§e/pvp see §7观赛:切换观察者⇄普通模式(仅PVP世界)");
p.sendMessage("§e/pvp join see §7加入观赛:进入观察者模式");
p.sendMessage("§e/pvp leave see §7退出观赛:离开观察者模式");
p.sendMessage("§e/pvp leave §7离开PVP竞技场");
p.sendMessage("§e/pvp stats [玩家] §7查看战绩");
p.sendMessage("§e/pvp list §7查看PVP区域列表");
+169 -85
View File
@@ -327,8 +327,14 @@ public class WebManager {
/**
* ★ 清空DB队列中所有未回应的web同步任务
* (清空队列/cleartake 命令调用)
*
* v2:以前只清 Java 内存队列,PHP 侧 web_login_requests 表里的 pending 记录
* 原封不动 → 下一轮轮询又原样取回来,看起来"打了几十次都没清掉"。
* 现在增加两件事:
* ① 复位本地"已处理"标记与TimerA在途计数(避免清完后仍被跳过/卡死)
* ② 异步调用 PHP clear_pending_web_logins,把服务端 pending 一并清理
*/
public void clearQueue() {
public String clearQueue() {
int count = 0;
Iterator<DbTask> it = dbTaskQueue.iterator();
while (it.hasNext()) {
@@ -342,7 +348,33 @@ public class WebManager {
count++;
}
}
plugin.getLogger().info("[DB队列] 清空队列: 丢弃 " + count + " 个未回应web任务");
// ★ 复位轮询在途标记:队列里的任务被丢弃后,finally不会执行,必须手工复位
processedWebLoginRequests.clear();
plugin.getLogger().info("[DB队列] 清空队列: 丢弃 " + count + " 个未回应web任务,联动清理PHP待处理请求...");
// ★ 联动清理 PHP 侧 pending(异步,不阻塞命令线程)
purgePhpPendingWebLogins(count);
return "丢弃 " + count + " 个未回应web任务 + 联动清理PHP待处理登录请求";
}
/**
* ★ 联动清理 PHP 的 web_login_requests pending 记录
* 必须使用 lambda 而非匿名内部类:jar 热替换后匿名内部类会抛
* NoClassDefFoundError(WebManager$N 找不到),导致回写/清理永远失败。
*/
private void purgePhpPendingWebLogins(final int localDropped) {
if (!enabled) return;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
String urlStr = webBaseUrl + "/api/sync.php?action=clear_pending_web_logins&secret="
+ java.net.URLEncoder.encode(secretKey, "UTF-8");
String resp = doGet(urlStr);
plugin.getLogger().info("[DB队列] 清空队列联动: 本地丢弃=" + localDropped
+ " PHP响应=" + (resp == null ? "null" : resp.substring(0, Math.min(200, resp.length()))));
} catch (Throwable t) {
plugin.getLogger().warning("[DB队列] 联动清理PHP待处理登录请求失败: " + t);
}
});
}
/**
@@ -1847,38 +1879,34 @@ public class WebManager {
// ★ CDK离线兑付:Timer A永不暂停,定期拉取CDK交易
// 解决全员下线时Timer B暂停导致CDK兑换不到账的bug
//
// ★★★ 队列保护(v2.93):原来这一轮会一口气投出【5个】子任务,
// 而 db-worker 是单线程、每个任务又含10~30秒级HTTP请求,
// Timer A 却每3~5秒就再投一轮 → 队列越堆越长
// (日志表现为「等待过久 10s+」「清空队列丢弃17个」依然慢)。
// 现在合并为【1个】队列任务串行执行:执行顺序与原来一致
// (单线程本来就是串行),但队列条目数降为 1/5。
if (timerACycleCount % 5 == 0) { // 每5轮(~15-25秒)检查一次
submitNormalDbTask("TimerA-cdkPull", () -> {
submitNormalDbTask("TimerA-周期批处理", () -> {
// 本地CDK(web_transactions pending)
try {
// 本地CDK(web_transactions pending)
doTransactionPollCheck();
} catch (Exception e) { /* 静默 */ }
});
// 远程CDK(cdk_validate_requests,sdf1计分板CDK验证)
if (initialSyncComplete) {
submitNormalDbTask("TimerA-cdkRemotePull", () -> {
try {
pullWebCdkRequestsAndValidate();
pullSdf1PendingAndValidateWeb();
} catch (Exception e) { /* 静默 */ }
});
}
// ★ PHP→Java变更轮询(管理员在PHP改了配置/领地,Java及时拉取)
if (initialSyncComplete) {
submitNormalDbTask("TimerA-pollAdminChanges", () -> {
try { pollAdminChanges(); } catch (Exception e) { /* 静默 */ }
});
if (!initialSyncComplete) return;
// 远程CDK(cdk_validate_requests,sdf1计分板CDK验证)
try {
pullWebCdkRequestsAndValidate();
pullSdf1PendingAndValidateWeb();
} catch (Exception e) { /* 静默 */ }
// ★ PHP→Java变更轮询(管理员在PHP改了配置/领地,Java及时拉取)
try { pollAdminChanges(); } catch (Exception e) { /* 静默 */ }
// ★ 过户cooldown检测:权限变更则取消过户
submitNormalDbTask("TimerA-transferCancellations", () -> {
try { handlePendingTransferCancellations(); } catch (Exception e) { /* 静默 */ }
});
}
// ★ 异步玩家验证轮询:拉取PHP的pending_player_validations,验证后推回结果
if (initialSyncComplete) {
submitNormalDbTask("TimerA-pollPlayerValidations", () -> {
try { pullPendingPlayerValidations(); } catch (Exception e) { /* 静默 */ }
});
}
try { handlePendingTransferCancellations(); } catch (Exception e) { /* 静默 */ }
// ★ 异步玩家验证轮询:拉取PHP的pending_player_validations,验证后推回结果
try { pullPendingPlayerValidations(); } catch (Exception e) { /* 静默 */ }
});
}
// 自调度下一轮(3~5秒,快速响应登录请求,错峰避免锁库)
@@ -3722,12 +3750,31 @@ public class WebManager {
}
lastBansHash = currentHash;
// ★ 封禁IP → 顺手推给Web黑名单(web_ip_blacklist)
// Web端据此拦截被封IP的访问,除非该IP持有游戏内 /web 签发的token
StringBuilder ipsb = new StringBuilder("[");
boolean ipFirst = true;
@SuppressWarnings("unchecked")
Set<org.bukkit.BanEntry<?>> ipOnlyEntries = (Set<org.bukkit.BanEntry<?>>)(Set<?>) ipBanList.getEntries();
for (org.bukkit.BanEntry<?> entry : ipOnlyEntries) {
if (!ipFirst) ipsb.append(",");
ipFirst = false;
long expireDate = entry.getExpiration() != null ? entry.getExpiration().getTime() : 0;
ipsb.append("{\"ip\":\"").append(escapeJson(entry.getTarget()))
.append("\",\"reason\":\"").append(escapeJson(entry.getReason() != null ? entry.getReason() : ""))
.append("\",\"source\":\"").append(escapeJson(entry.getSource() != null ? entry.getSource() : ""))
.append("\",\"expire\":").append(expireDate).append("}");
}
ipsb.append("]");
// ★ 改用POST避免GET URL过长(55条封禁URL可达数KB)
Map<String, Object> body = new LinkedHashMap<>();
body.put("secret", secretKey);
body.put("bans", sb.toString());
body.put("ips", ipsb.toString());
String resp = httpPost("api/sync.php?action=sync_bans", mapToJson(body));
plugin.getLogger().info("[防护-sync] 封禁名单同步: " + (nameEntries.size() + ipEntries.size()) + "条 → " + resp);
plugin.getLogger().info("[防护-sync] 封禁名单同步: " + (nameEntries.size() + ipEntries.size())
+ "条(IP黑名单 " + ipOnlyEntries.size() + "个) → " + resp);
} catch (Exception e) {
plugin.getLogger().warning("[防护-sync] 封禁名单同步异常: " + e.getMessage());
}
@@ -6328,7 +6375,9 @@ public class WebManager {
if (arrStr.trim().equals("[]")) {
return;
}
plugin.getLogger().info("[Web密码验证轮询] ★ 发现待处理请求,数组长度=" + arrStr.length());
int pendingCount = countTopLevelJsonObjects(arrStr);
plugin.getLogger().info("[Web密码验证轮询] ★ 发现待处理请求,条数=" + pendingCount
+ "(JSON长度=" + arrStr.length() + ")");
int idx = 0;
while (true) {
int objStart = arrStr.indexOf("{", idx);
@@ -6376,7 +6425,15 @@ public class WebManager {
}
// 异步将结果写回PHP(供Web端查询)
sendWebLoginResult(fReqId, fName, result);
try {
sendWebLoginResult(fReqId, fName, result);
} catch (Throwable t) {
// ★ 热替换jar后类加载器可能找不到匿名/内部类(Error不是Exception,
// 外层catch接不住)→ 复位处理标记,下一轮重新验证重试
plugin.getLogger().warning("[Web密码验证] 回写调度失败: " + t
+ " → 已复位处理标记,稍后重试");
processedWebLoginRequests.remove(fReqId);
}
} catch (Exception e) {
plugin.getLogger().warning("[Web密码验证] 处理异常: player=" + fName + " error=" + e.getMessage());
}
@@ -6394,62 +6451,67 @@ public class WebManager {
/**
* 将密码验证结果写回PHP后端
* 使用GET请求传所有参数(避免POST body解析问题),同时增加重试机制
*
* ★ 用 lambda 而非匿名内部类(原 WebManager$15):
* jar 被热替换后 PluginClassLoader 找不到旧的匿名内部类,
* 抛 NoClassDefFoundError → 回写永远失败 → PHP pending 永不清空。
* lambda 编译为 WebManager 自身的合成方法,类已加载,不受热替换影响。
*/
private void sendWebLoginResult(String reqId, String playerName, String result) {
new BukkitRunnable() {
@Override
public void run() {
// 将result转换为JSON对象格式 {"success": true/false, "status": "..."}
String resultJson;
if ("\"success\"".equals(result)) {
resultJson = "{\"success\":true,\"status\":\"success\"}";
} else if ("\"not_registered\"".equals(result)) {
resultJson = "{\"success\":false,\"status\":\"not_registered\"}";
} else {
resultJson = "{\"success\":false,\"status\":\"failed\"}";
}
plugin.getLogger().info("[Web密码验证回写] ★ 开始回写PHP player=" + playerName
+ " reqId=" + reqId + " result=" + result);
// 使用GET请求,所有参数通过URL传递,避免POST body解析问题
// 最多重试3次,每次间隔2秒
boolean sent = false;
for (int attempt = 0; attempt < 3; attempt++) {
try {
String resultEncoded = java.net.URLEncoder.encode(resultJson, "UTF-8");
String urlStr = webBaseUrl + "/api/sync.php?action=complete_web_login_request"
+ "&secret=" + java.net.URLEncoder.encode(secretKey, "UTF-8")
+ "&request_id=" + reqId
+ "&player=" + java.net.URLEncoder.encode(playerName, "UTF-8")
+ "&result=" + resultEncoded;
String resp = doGet(urlStr);
if (resp != null) {
sent = true;
plugin.getLogger().info("[Web密码验证回写] ✓ 成功: player=" + playerName
+ " reqId=" + reqId + " result=" + result
+ " PHP响应=" + resp.substring(0, Math.min(200, resp.length()))
+ " (第" + (attempt + 1) + "次)");
break;
} else {
plugin.getLogger().warning("[Web密码验证回写] ✗ GET失败: player=" + playerName
+ " reqId=" + reqId + " (第" + (attempt + 1) + "次)");
}
} catch (Exception e) {
plugin.getLogger().warning("[Web密码验证回写] ✗ 异常: player=" + playerName
+ " reqId=" + reqId + " (第" + (attempt + 1) + "次) " + e.getMessage());
}
// 重试前等待
try { Thread.sleep(2000); } catch (InterruptedException ie) { break; }
}
if (!sent) {
plugin.getLogger().warning("[Web密码验证回写] ✗ 最终失败,已重试3次: player=" + playerName + " reqId=" + reqId);
}
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
// 将result转换为JSON对象格式 {"success": true/false, "status": "..."}
String resultJson;
if ("\"success\"".equals(result)) {
resultJson = "{\"success\":true,\"status\":\"success\"}";
} else if ("\"not_registered\"".equals(result)) {
resultJson = "{\"success\":false,\"status\":\"not_registered\"}";
} else {
resultJson = "{\"success\":false,\"status\":\"failed\"}";
}
}.runTaskAsynchronously(plugin);
plugin.getLogger().info("[Web密码验证回写] ★ 开始回写PHP player=" + playerName
+ " reqId=" + reqId + " result=" + result);
// 使用GET请求,所有参数通过URL传递,避免POST body解析问题
// 最多重试3次,每次间隔2秒
boolean sent = false;
for (int attempt = 0; attempt < 3; attempt++) {
try {
String resultEncoded = java.net.URLEncoder.encode(resultJson, "UTF-8");
String urlStr = webBaseUrl + "/api/sync.php?action=complete_web_login_request"
+ "&secret=" + java.net.URLEncoder.encode(secretKey, "UTF-8")
+ "&request_id=" + reqId
+ "&player=" + java.net.URLEncoder.encode(playerName, "UTF-8")
+ "&result=" + resultEncoded;
String resp = doGet(urlStr);
if (resp != null) {
sent = true;
plugin.getLogger().info("[Web密码验证回写] ✓ 成功: player=" + playerName
+ " reqId=" + reqId + " result=" + result
+ " PHP响应=" + resp.substring(0, Math.min(200, resp.length()))
+ " (第" + (attempt + 1) + "次)");
break;
} else {
plugin.getLogger().warning("[Web密码验证回写] ✗ GET失败: player=" + playerName
+ " reqId=" + reqId + " (第" + (attempt + 1) + "次)");
}
} catch (Exception e) {
plugin.getLogger().warning("[Web密码验证回写] ✗ 异常: player=" + playerName
+ " reqId=" + reqId + " (第" + (attempt + 1) + "次) " + e.getMessage());
}
// 重试前等待
try { Thread.sleep(2000); } catch (InterruptedException ie) { break; }
}
if (!sent) {
plugin.getLogger().warning("[Web密码验证回写] ✗ 最终失败,已重试3次: player=" + playerName + " reqId=" + reqId);
// ★ 回写失败 → 复位"已处理"标记,下一轮轮询重新验证并重试写回,
// 否则该请求会在PHP里挂满10分钟过期,Web端一直转圈
processedWebLoginRequests.remove(reqId);
}
});
}
/**
@@ -6491,6 +6553,28 @@ public class WebManager {
return -1;
}
/**
* 统计JSON数组中顶层对象 {…} 的个数(用于日志显示真实待处理条数,
* 以前打印的是字符串长度,容易被误读成条数)
*/
private static int countTopLevelJsonObjects(String arr) {
if (arr == null || arr.isEmpty()) return 0;
int count = 0, depth = 0;
boolean inString = false;
for (int i = 0; i < arr.length(); i++) {
char c = arr.charAt(i);
if (inString) {
if (c == '\\' && i + 1 < arr.length()) { i++; continue; }
if (c == '"') inString = false;
continue;
}
if (c == '"') { inString = true; continue; }
if (c == '{') { if (depth == 0) count++; depth++; }
else if (c == '}') { if (depth > 0) depth--; }
}
return count;
}
// ==================== 新增功能 ====================
// 日志控制:避免频繁打印