feat: 完善GUI验证码点击验证逻辑 + 修复ChatFilterManager checkAnswer路由

This commit is contained in:
2026-07-04 14:52:30 +08:00
parent afa53876ad
commit d5cebbf6ad
4 changed files with 315 additions and 76 deletions
+21
View File
@@ -0,0 +1,21 @@
## 2026-07-04 13:33 - 新玩家验证码系统 + 全场景链接过滤 + 私信URL拦截
### 验证码系统实现
- ChatFilterManager 新增 VerificationResult 枚举(NEED_VERIFICATION/PENDING/VERIFIED/FAILED)
- 新玩家首次发消息时触发数学题验证码(10以内加减乘除,随机选择)
- 30秒有效期,答错/超时/非数字答案 → 吞噬消息
- 答对 → 插件代为广播缓存的原始消息(无前缀,像普通聊天一样)
- 老玩家(注册>1小时)快速验证后自动放行
- pendingMessages Map 缓存未验证消息,验证通过后广播
### 全场景链接过滤
- 告示牌(onSignChange): 已有的 filterLinkInContent 拦截
- 编辑书(onPlayerEditBook): 通过反射调用 getNewBook() 获取书页内容(兼容Paper 1.21.4)
- 私信(onPrivateMessage): 新增 PlayerCommandPreprocessEvent 拦截 /tell /msg /w /r 命令中的链接
### 编译部署
- Maven clean package 编译通过
- 推送到 git.ypshidifu.cn (main 分支)
- JAR 上传到 SFTP 服务器
- PHP 文件上传到 FTP /plugin/ 目录
+231 -60
View File
@@ -1,7 +1,11 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.*;
@@ -47,21 +51,37 @@ public class ChatFilterManager {
private int muteDuration = 300;
private boolean enabled = true;
// ★ 验证码数据存储
// ============================================================
// ★ 新玩家验证码系统
// ============================================================
/** 已验证过的玩家名单(永久,跨session持久化到内存) */
private final Set<String> verifiedPlayers = ConcurrentHashMap.newKeySet();
/** 验证码数据存储 */
private final Map<String, VerificationData> verificationData = new ConcurrentHashMap<>();
// ============================================================
// ★ 新玩家验证码系统
// ============================================================
/** 验证码类型 */
enum VerificationType {
MATH, // 数学题
GUI // GUI物品选择
}
static class VerificationData {
long createTime;
int a, b, op; // 0=加法, 1=减法, 2=乘法
String guiTargets; // GUI模式下需要选择的物品名列表("|"分隔)
VerificationType type; // 验证码类型
boolean completed;
VerificationData(int a, int b, int op) {
VerificationData(int a, int b, int op, VerificationType type) {
this.createTime = System.currentTimeMillis();
this.a = a; this.b = b; this.op = op;
this.type = type;
}
boolean isExpired() {
@@ -91,68 +111,74 @@ public class ChatFilterManager {
*/
public VerificationResult checkNewPlayerVerification(Player p) {
String name = p.getName();
// ★ 永久已验证的玩家 → 直接放行
if (verifiedPlayers.contains(name)) {
return VerificationResult.VERIFIED;
}
// 已验证过的玩家不需要
// 正在验证中(等待答案)
if (verificationData.containsKey(name)) {
VerificationData vd = verificationData.get(name);
if (vd.completed) {
verifiedPlayers.add(name);
verificationData.remove(name);
return VerificationResult.VERIFIED;
}
// 正在进行验证(等待答案)
// 验证码是否过期
if (vd.isExpired()) {
verificationData.remove(name);
return VerificationResult.NEED_VERIFICATION;
// 过期后不再要求验证(避免玩家反复被卡)
verifiedPlayers.add(name);
return VerificationResult.VERIFIED;
}
return VerificationResult.PENDING;
}
// 检查玩家加入时间是否≤1小时
if (!isNewPlayerUnder1Hour(p)) {
// 老玩家:快速验证并标记为已完成
generateMathVerification(name);
verificationData.get(name).completed = true;
verificationData.remove(name);
return VerificationResult.VERIFIED;
if (isNewPlayerUnder1Hour(p)) {
return VerificationResult.NEED_VERIFICATION;
}
// 新玩家 → 需要验证
return VerificationResult.NEED_VERIFICATION;
// 老玩家:直接永久验证通过
verifiedPlayers.add(name);
return VerificationResult.VERIFIED;
}
/**
* 生成数学题验证码
* 生成随机验证码:数学题或GUI随机50%概率
*/
public void generateMathVerification(String playerName) {
Random rand = new Random();
VerificationType vType = rand.nextBoolean() ? VerificationType.MATH : VerificationType.GUI;
if (vType == VerificationType.MATH) {
generateMathChallenge(playerName, rand);
} else {
generateGUIChallenge(playerName, rand);
}
}
/** 生成数学题验证码 */
private void generateMathChallenge(String playerName, Random rand) {
int op = rand.nextInt(3); // 0=加, 1=减, 2=乘
int a, b, answer;
int a, b;
switch (op) {
case 0: // 加法
a = rand.nextInt(10) + 1;
b = rand.nextInt(10) + 1;
answer = a + b;
break;
case 1: // 减法
a = rand.nextInt(10) + 2;
b = rand.nextInt(a) + 1;
answer = a - b;
break;
default: // 乘法
a = rand.nextInt(10) + 1;
b = rand.nextInt(10) + 1;
answer = a * b;
break;
case 0: a = rand.nextInt(10) + 1; b = rand.nextInt(10) + 1; break;
case 1: a = rand.nextInt(10) + 2; b = rand.nextInt(a) + 1; break;
default: a = rand.nextInt(10) + 1; b = rand.nextInt(10) + 1; break;
}
VerificationData vd = new VerificationData(a, b, op, VerificationType.MATH);
verificationData.put(playerName, vd);
String opSymbol = switch (op) {
case 0 -> "+";
case 1 -> "-";
default -> "×";
};
verificationData.put(playerName, new VerificationData(a, b, op));
org.bukkit.Bukkit.getScheduler().runTask(plugin, () -> {
Player p = org.bukkit.Bukkit.getPlayer(playerName);
if (p != null && p.isOnline()) {
@@ -161,40 +187,173 @@ public class ChatFilterManager {
}
});
}
/** 生成GUI验证码:在背包放3个物品,让玩家按顺序点击指定位置 */
private void generateGUIChallenge(String playerName, Random rand) {
// 选3个不同材料
Material[] materials = {Material.STONE, Material.COBBLESTONE, Material.GRAVEL,
Material.SAND, Material.CLAY_BALL, Material.REDSTONE,
Material.LAPIS_LAZULI, Material.QUARTZ};
List<Material> pool = Arrays.asList(materials);
ArrayList<Material> shuffled = new ArrayList<>(pool);
Collections.shuffle(shuffled, rand);
Material item1 = shuffled.get(0);
Material item2 = shuffled.get(1);
Material item3 = shuffled.get(2);
// 记录物品名(字符串形式),用 | 分隔
String targets = item1.name() + "|" + item2.name() + "|" + item3.name();
int[] slots = {10, 13, 16}; // 中间行的左中右
VerificationData vd = new VerificationData(0, 0, 0, VerificationType.GUI);
vd.guiTargets = targets;
vd.type = VerificationType.GUI;
verificationData.put(playerName, vd);
org.bukkit.Bukkit.getScheduler().runTask(plugin, () -> {
Player p = org.bukkit.Bukkit.getPlayer(playerName);
if (p != null && p.isOnline()) {
try {
Inventory inv = Bukkit.createInventory(null, 27, "§e§l点击验证码");
// 填充玻璃
ItemStack glass = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta gm = glass.getItemMeta();
if (gm != null) { gm.setDisplayName(" "); glass.setItemMeta(gm); }
for (int i = 0; i < 27; i++) inv.setItem(i, glass);
// 放3个目标物品
inv.setItem(slots[0], mkItem(item1, "§e" + item1.name().replace("_", " "), "§7点击此项"));
inv.setItem(slots[1], mkItem(item2, "§e" + item2.name().replace("_", " "), "§7点击此项"));
inv.setItem(slots[2], mkItem(item3, "§e" + item3.name().replace("_", " "), "§7点击此项"));
p.openInventory(inv);
p.sendMessage("§e§l[验证码] §f请按 §e顺序 §f点击标注的物品");
p.sendMessage("§7(30秒内完成)");
} catch (Exception ex) {
plugin.getLogger().warning("[验证码] GUI挑战生成失败: " + ex.getMessage());
// 降级为数学题
generateMathChallenge(playerName, rand);
}
}
});
}
// 辅助方法:创建带显示名的物品
private ItemStack mkItem(Material mat, String displayName, String lore) {
ItemStack stack = new ItemStack(mat);
ItemMeta meta = stack.getItemMeta();
if (meta != null) {
meta.setDisplayName(displayName);
List<String> lores = new ArrayList<>();
lores.add(lore);
meta.setLore(lores);
stack.setItemMeta(meta);
}
return stack;
}
/**
* 检查玩家回答是否正确(数学题或GUI验证码)
*/
public VerificationResult checkAnswer(String playerName, String answerStr) {
VerificationData vd = verificationData.get(playerName);
if (vd == null) return VerificationResult.VERIFIED;
if (vd.isExpired()) {
verificationData.remove(playerName);
verifiedPlayers.add(playerName);
return VerificationResult.VERIFIED;
}
if (vd.type == VerificationType.MATH) {
return checkMathAnswer(playerName, answerStr);
}
// GUI验证码 — 聊天输入不作为答案,返回PENDING等待GUI点击
return VerificationResult.PENDING;
}
/**
* 检查玩家回答是否正确
* @return VERIFIED=正确放行, FAILED=错误吞噬
* @deprecated 使用 checkAnswer 代替
*/
@Deprecated
public VerificationResult checkMathAnswer(String playerName, String answerStr) {
VerificationData vd = verificationData.get(playerName);
if (vd == null) return VerificationResult.VERIFIED; // 无验证码 → 放行
if (vd.isExpired()) {
verificationData.remove(playerName);
return VerificationResult.FAILED; // 过期 → 吞
verifiedPlayers.add(playerName); // 过期后不再要求
return VerificationResult.VERIFIED;
}
try {
int ans = Integer.parseInt(answerStr.trim());
int expected;
switch (vd.op) {
case 0 -> expected = vd.a + vd.b;
case 1 -> expected = vd.a - vd.b;
default -> expected = vd.a * vd.b;
}
if (ans == expected) {
vd.completed = true;
verificationData.remove(playerName);
return VerificationResult.VERIFIED;
} else {
verificationData.remove(playerName);
if (vd.type == VerificationType.MATH) {
try {
int ans = Integer.parseInt(answerStr.trim());
int expected;
switch (vd.op) {
case 0 -> expected = vd.a + vd.b;
case 1 -> expected = vd.a - vd.b;
default -> expected = vd.a * vd.b;
}
if (ans == expected) {
vd.completed = true;
verificationData.remove(playerName);
verifiedPlayers.add(playerName); // 永久记录
return VerificationResult.VERIFIED;
} else {
verificationData.remove(playerName);
return VerificationResult.FAILED;
}
} catch (NumberFormatException e) {
return VerificationResult.FAILED;
}
} catch (NumberFormatException e) {
return VerificationResult.FAILED; // 非数字 → 吞
}
// GUI验证码 — answerStr 为空时表示未回答
if (answerStr == null || answerStr.isEmpty()) {
return VerificationResult.PENDING;
}
return VerificationResult.FAILED;
}
/**
* 处理GUI物品点击验证码
* @param playerName 玩家名
* @param clickedItemName 点击的物品Material名(如"STONE")
* @return 验证结果
*/
public VerificationResult checkGUIClick(String playerName, String clickedItemName) {
String name = playerName;
VerificationData vd = verificationData.get(name);
if (vd == null) return VerificationResult.VERIFIED;
if (!VerificationType.GUI.equals(vd.type)) return VerificationResult.PENDING;
if (vd.isExpired()) {
verificationData.remove(name);
verifiedPlayers.add(name);
return VerificationResult.VERIFIED;
}
// 检查点击的物品名是否在目标列表中
if (vd.guiTargets != null && clickedItemName != null) {
String[] targets = vd.guiTargets.split("\\|");
for (String t : targets) {
if (t.equals(clickedItemName)) {
vd.completed = true;
verificationData.remove(name);
verifiedPlayers.add(name);
return VerificationResult.VERIFIED;
}
}
}
return VerificationResult.FAILED; // 没匹配的 → 吞
}
// ★ 临时缓存玩家未验证的消息,等验证通过后由插件代为广播
@@ -225,22 +384,34 @@ public class ChatFilterManager {
}
/**
* 检查玩家是否为新玩家(加入≤1小时)
* 检查玩家是否为新玩家(register_time 在1小时内)
*/
public boolean isNewPlayerUnder1Hour(Player p) {
if (p == null || !p.isOnline()) return false;
try {
Main mainPlugin = (Main) plugin;
DatabaseManager db = mainPlugin.getDb();
if (db == null) return true; // 默认放行
if (db == null) return false; // 查不到就不拦截,直接放行
// register_time 在数据库存的是秒级时间戳
Object regTimeObj = db.getField(p.getName(), "register_time");
if (regTimeObj == null || ((Number) regTimeObj).longValue() == 0) {
return true; // 没有注册时间 → 新玩家
if (regTimeObj == null) return false; // 没注册记录 → 放行
long regTimeSeconds = ((Number) regTimeObj).longValue();
if (regTimeSeconds == 0) return false; // 注册时间为0 → 放行
long regTimeMs = regTimeSeconds * 1000;
long elapsed = System.currentTimeMillis() - regTimeMs;
boolean isNew = elapsed < 3600000;
// 日志调试:打印注册时间差值
if (isNew) {
plugin.getLogger().info("[验证码] 新玩家: " + p.getName() + " 注册" + (elapsed/1000) + "秒前");
}
long regTime = ((Number) regTimeObj).longValue() * 1000;
return (System.currentTimeMillis() - regTime) < 3600000;
return isNew;
} catch (Exception e) {
return true; // 出错时默认放行
return false; // 出错时默认放行,不卡玩家
}
}
+37 -14
View File
@@ -2598,38 +2598,38 @@ public class Main extends JavaPlugin
ChatFilterManager.VerificationResult vr = chatFilter.checkNewPlayerVerification(p);
if (vr == ChatFilterManager.VerificationResult.NEED_VERIFICATION) {
// 第一次发送消息 → 触发验证码
// 缓存玩家原始消息,等验证通过后由插件代为广播
// 首次触发验证码 → 缓存消息, 拦截
chatFilter.cachePendingMessage(p.getName(), msg);
chatFilter.generateMathVerification(p.getName());
e.setCancelled(true);
return; // 消息拦截,验证码通过后再广播
return;
}
if (vr == ChatFilterManager.VerificationResult.PENDING) {
// 验证码进行中,判断输入是否为答案
ChatFilterManager.VerificationResult checkResult = chatFilter.checkMathAnswer(p.getName(), msg);
// 验证进行中:当前聊天框输入就是答案
ChatFilterManager.VerificationResult checkResult = chatFilter.checkAnswer(p.getName(), msg);
if (checkResult == ChatFilterManager.VerificationResult.VERIFIED) {
// 验证通过 → 广播缓存的消息
// 消息格式:去掉插件前缀,像普通聊天消息一样
String cachedMsg = chatFilter.pendingMessages.get(p.getName());
if (cachedMsg != null) {
Bukkit.broadcastMessage(p.getDisplayName() + ": " + cachedMsg);
chatFilter.pendingMessages.remove(p.getName());
}
e.setCancelled(true); // 阻止原事件重复广播
return;
} else {
// 验证失败 → 吞噬缓存的消息
chatFilter.pendingMessages.remove(p.getName());
p.sendMessage("§a§l[验证码] §a验证通过!");
e.setCancelled(true);
return;
} else if (checkResult == ChatFilterManager.VerificationResult.FAILED) {
chatFilter.pendingMessages.remove(p.getName());
e.setCancelled(true);
p.sendMessage("§c§l[验证码] §c回答错误,消息已被拦截");
return;
}
// PENDING → GUI验证码,忽略聊天输入,让消息被缓存等待GUI点击
e.setCancelled(true);
return;
}
// VERIFIED → 正常放行聊天
}
// ===== 以下是正常放行后的逻辑(老玩家/已验证玩家) =====
// ★ 广告机检测(独立于URL过滤,覆盖私信/全场景)
if (chatFilter != null && chatFilter.checkAdBotBehavior(p.getName(), msg)) {
e.setCancelled(true);
@@ -3178,7 +3178,30 @@ public class Main extends JavaPlugin
return;
Player p = (Player) e.getWhoClicked();
// ===== 验证码GUI点击处理 =====
String title = e.getView().getTitle();
if ("§e§l点击验证码".equals(title)) {
e.setCancelled(true);
if (e.getCurrentItem() == null || e.getCurrentItem().getType() == Material.AIR) return;
String clickedMat = e.getCurrentItem().getType().name();
if (chatFilter != null && chatFilter.isEnabled()) {
ChatFilterManager.VerificationResult vr = chatFilter.checkGUIClick(p.getName(), clickedMat);
String cachedMsg = chatFilter.pendingMessages.get(p.getName());
if (vr == ChatFilterManager.VerificationResult.VERIFIED) {
p.closeInventory();
p.sendMessage("§a§l[验证码] §a验证通过!");
if (cachedMsg != null) {
Bukkit.broadcastMessage(p.getDisplayName() + ": " + cachedMsg);
chatFilter.pendingMessages.remove(p.getName());
}
} else {
p.closeInventory();
chatFilter.pendingMessages.remove(p.getName());
p.sendMessage("§c§l[验证码] §c点击错误,消息已被拦截");
}
}
return;
}
// 传送面板不受登录冻结限制(基岩版玩家免登录也要能用)
if (!title.equals("§6§l传送系统")) {
+26 -2
View File
@@ -49,6 +49,9 @@ public class TeleportManager implements Listener {
// 请求过期时间 (请求ID/发送者→ 创建时间戳),配置化
private final Map<String, Long> teleportRequestTimes = new ConcurrentHashMap<>();
private volatile long teleportCooldownMs = 30_000L; // 可配置的冷却时间
// ★ 已处理/已拒绝的请求(不能再使用)
private final Set<String> processedRequests = ConcurrentHashMap.newKeySet();
// ★ 管理员配置状态 (玩家名 → 配置状态)
// 状态值: "valid" = 等待输入有效时间, "interval" = 等待输入间隔时间
@@ -228,15 +231,28 @@ public class TeleportManager implements Listener {
* 检查传送请求是否有效(未被处理过且未过期)
*/
private boolean isValidRequest(String receiver, String sender) {
String key = sender + ":" + receiver;
// ★ 检查是否已处理过
if (processedRequests.contains(key)) {
return false;
}
Set<String> senders = incomingRequests.get(receiver);
if (senders == null || !senders.contains(sender)) {
return false;
}
// ★ 修复:teleportRequestTimes 存的是 sender:receiver(发送者在前),不是 receiver:sender
String key = sender + ":" + receiver;
return !isTeleportRequestExpired(key);
}
/**
* 标记请求为已处理(防止二次接受/拒绝)
*/
private void markRequestProcessed(String sender, String receiver) {
String key = sender + ":" + receiver;
processedRequests.add(key);
}
// ==================== 判断玩家类型 ====================
/**
@@ -672,6 +688,8 @@ public class TeleportManager implements Listener {
// 清理请求
removeIncomingRequest(targetName, senderName);
removeOutgoingRequest(senderName, targetName);
// ★ 标记自动接受的请求为已处理
markRequestProcessed(senderName, targetName);
return true;
}
@@ -807,6 +825,9 @@ public class TeleportManager implements Listener {
removeIncomingRequest(player.getName(), targetName);
removeOutgoingRequest(targetName, player.getName());
// ★ 标记请求已处理(防止二次使用)
markRequestProcessed(targetName, player.getName());
player.sendMessage("§a[传送] 已传送到 §f" + targetName + " §a身边");
actualSender.sendMessage("§a[传送] §f" + player.getName() + " §a已接受请求");
teleportCooldown.remove(actualSender.getName());
@@ -912,6 +933,9 @@ public class TeleportManager implements Listener {
removeIncomingRequest(player.getName(), targetName);
removeOutgoingRequest(targetName, player.getName());
// ★ 标记请求已拒绝(防止二次使用)
markRequestProcessed(targetName, player.getName());
player.sendMessage("§6[传送] 已拒绝来自 §f" + targetName + " §c的传送请求");
return true;