新增/tempban临时封禁、/chat mute禁言、异地登录检测与账号冻结解冻

- DurationParser: 通用时长解析(时间戳/日期/相对时长)
- /tempban: Bukkit原生封禁(1秒~10年)
- ChatFilterManager: 持久化禁言(muted_until)+loadMutes启动加载
- 异地登录检测: onJoin异步通知PHP比对IP归属(ip9.com.cn)
- freeze/unfreeze: web_admin_changes通道执行/解除Bukkit原生封禁
- PVPArenaManager: 移植PVP全服连杀/终结播报逻辑
- DatabaseManager/plugin.yml: 风控列 + tempban命令注册
This commit is contained in:
2026-07-09 17:09:35 +08:00
parent 2e417c67b3
commit 41d8ffce3d
7 changed files with 584 additions and 3 deletions
@@ -36,6 +36,10 @@ public class ChatFilterManager {
private final Map<String, Long>
mutedPlayers =
new ConcurrentHashMap<>();
// ★ 禁言理由(内存镜像,持久化到 users.muted_until / mute_reason)
private final Map<String, String>
muteReasons =
new ConcurrentHashMap<>();
private final Map<String, String> messages =
new LinkedHashMap<>();
// ★ 广告机检测:记录每个玩家发送链接的频率
@@ -889,6 +893,8 @@ public class ChatFilterManager {
if (System.currentTimeMillis()
>= expiry) {
mutedPlayers.remove(name);
muteReasons.remove(name);
clearMuteDb(name);
return false;
}
return true;
@@ -981,6 +987,76 @@ public class ChatFilterManager {
public void unmutePlayer(String name) {
mutedPlayers.remove(name);
muteReasons.remove(name);
clearMuteDb(name);
}
// ========== 禁言持久化(重启后仍生效) ==========
/** 禁言指定玩家,到期时间戳为 expireMs(绝对毫秒) */
public void mutePlayer(String name, long expireMs,
String reason) {
mutedPlayers.put(name, expireMs);
if (reason != null && !reason.isEmpty())
muteReasons.put(name, reason);
persistMute(name, expireMs, reason);
plugin.getLogger().info(
"[聊天] 已禁言 " + name + " 至 "
+ new java.util.Date(expireMs));
}
private void persistMute(String name, long expireMs,
String reason) {
try {
Main mainPlugin = (Main) plugin;
DatabaseManager db = mainPlugin.getDb();
if (db == null) return;
db.setField(name, "muted_until", expireMs);
db.setField(name, "mute_reason",
reason == null ? "" : reason);
} catch (Exception e) {
e.printStackTrace();
}
}
private void clearMuteDb(String name) {
try {
Main mainPlugin = (Main) plugin;
DatabaseManager db = mainPlugin.getDb();
if (db == null) return;
db.setField(name, "muted_until", 0);
db.setField(name, "mute_reason", "");
} catch (Exception e) {
e.printStackTrace();
}
}
/** 插件启动时从数据库加载仍有效的禁言 */
public void loadMutes() {
try {
Main mainPlugin = (Main) plugin;
DatabaseManager db = mainPlugin.getDb();
if (db == null) return;
for (Object[] row : db.getActiveMutes()) {
String name = (String) row[0];
long expire = (Long) row[1];
String reason = (String) row[2];
mutedPlayers.put(name, expire);
if (reason != null
&& !reason.isEmpty())
muteReasons.put(name, reason);
}
if (!mutedPlayers.isEmpty())
plugin.getLogger().info(
"[聊天] 已加载 " + mutedPlayers.size()
+ " 条有效禁言");
} catch (Exception e) {
e.printStackTrace();
}
}
public String getMuteReason(String name) {
return muteReasons.get(name);
}
public void resetPlayer(String name) {
@@ -91,6 +91,22 @@ public class DatabaseManager {
// UI偏好:1=CLI(默认), 0=GUI
safeAdd(st, "ui_mode",
"INTEGER DEFAULT 1");
// ===== 风控 / 封禁相关列 =====
// IP归属(省+市+区),用于异地登录检测
safeAdd(st, "last_login_location",
"TEXT DEFAULT ''");
// 账号冻结(玩家主动冻结防被盗):0=正常 1=已冻结
safeAdd(st, "frozen",
"INTEGER DEFAULT 0");
// 冻结校验 token(邮件链接防猜测)
safeAdd(st, "freeze_token",
"TEXT DEFAULT ''");
// 禁言到期时间戳(毫秒),0=未禁言
safeAdd(st, "muted_until",
"INTEGER DEFAULT 0");
// 禁言理由
safeAdd(st, "mute_reason",
"TEXT DEFAULT ''");
// ===== 背包备份表:安全迁移 =====
// 检查旧表是否存在(用 inventory_data 列判断)
@@ -684,6 +700,36 @@ public class DatabaseManager {
}
}
/** 读取当前仍有效的禁言列表(muted_until > now) */
public List<Object[]> getActiveMutes() {
List<Object[]> list =
new ArrayList<>();
try {
PreparedStatement ps = db.prepareStatement(
"SELECT player_name, muted_until, "
+ "mute_reason FROM users "
+ "WHERE muted_until > ?");
ps.setLong(1,
System.currentTimeMillis());
ResultSet rs = ps.executeQuery();
while (rs.next()) {
Object[] row = new Object[3];
row[0] = rs.getString(
"player_name");
row[1] = rs.getLong(
"muted_until");
row[2] = rs.getString(
"mute_reason");
list.add(row);
}
rs.close();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
public Object getField(String name,
String field) {
try {
@@ -0,0 +1,184 @@
package Sdf1_login;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 统一时长解析器(供 /tempban 与 /chat mute 共用)
*
* parseToExpireMs(String) 返回「绝对到期时间戳(毫秒)」,调用方自行校验最小/最大区间。
*
* 支持的写法:
* 1) 纯数字(Unix 秒时间戳): 253402271999
* 2) 中文日期(绝对): 2026年7月9日
* 3) ISO 日期(绝对): 2025-07-09
* 4) 相对时长(中文/英文单位): 1秒 / 1s / 1年 / 1年2月3天 / 2天12小时
*
* 解析规则优先级:纯数字 → 时间戳;含「年/月/日」且为完整日期格式 → 绝对日期;
* 否则按「数字+单位」逐段累加为相对时长。
*/
public class DurationParser {
private static final Map<String, Long> UNIT_MS = new HashMap<>();
static {
// 秒
UNIT_MS.put("秒", 1000L);
UNIT_MS.put("秒钟", 1000L);
UNIT_MS.put("s", 1000L);
UNIT_MS.put("sec", 1000L);
// 分
UNIT_MS.put("分", 60000L);
UNIT_MS.put("分钟", 60000L);
UNIT_MS.put("mins", 60000L);
UNIT_MS.put("min", 60000L);
UNIT_MS.put("m", 60000L);
// 时
UNIT_MS.put("时", 3600000L);
UNIT_MS.put("小时", 3600000L);
UNIT_MS.put("钟头", 3600000L);
UNIT_MS.put("h", 3600000L);
UNIT_MS.put("hr", 3600000L);
// 天
UNIT_MS.put("天", 86400000L);
UNIT_MS.put("日", 86400000L);
UNIT_MS.put("d", 86400000L);
// 周
UNIT_MS.put("周", 604800000L);
UNIT_MS.put("星期", 604800000L);
UNIT_MS.put("礼拜", 604800000L);
UNIT_MS.put("w", 604800000L);
// 月(按30天近似)
UNIT_MS.put("个月", 2592000000L);
UNIT_MS.put("月", 2592000000L);
UNIT_MS.put("mo", 2592000000L);
// 年(按365天近似)
UNIT_MS.put("年", 31536000000L);
UNIT_MS.put("y", 31536000000L);
}
/** 解析为绝对到期毫秒;无法解析抛出 IllegalArgumentException */
public static long parseToExpireMs(String input)
throws IllegalArgumentException {
if (input == null || input.trim().isEmpty())
throw new IllegalArgumentException("时长为空");
String s = input.trim().replace(" ", "");
// 1) 纯数字 → Unix 秒时间戳
if (s.matches("\\d+")) {
long ts = Long.parseLong(s);
return ts * 1000L;
}
// 2) 中文日期 YYYY年M月D日
Matcher mc = Pattern.compile(
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日").matcher(s);
if (mc.find()) {
return buildDateMillis(Integer.parseInt(mc.group(1)),
Integer.parseInt(mc.group(2)),
Integer.parseInt(mc.group(3)));
}
// 3) ISO 日期 YYYY-MM-DD / YYYY/MM/DD
Matcher mi = Pattern.compile(
"(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})").matcher(s);
if (mi.find()) {
return buildDateMillis(Integer.parseInt(mi.group(1)),
Integer.parseInt(mi.group(2)),
Integer.parseInt(mi.group(3)));
}
// 4) 相对时长(可多段组合)
long rel = parseRelative(s);
if (rel >= 0) {
return System.currentTimeMillis() + rel;
}
throw new IllegalArgumentException("无法解析时长: " + input);
}
private static long parseRelative(String s) {
// 按「数字 + 单位」逐段匹配并累加
StringBuilder units = new StringBuilder();
for (String u : UNIT_MS.keySet()) {
if (units.length() > 0) units.append("|");
// 转义,避免正则特殊字符
units.append(Pattern.quote(u));
}
Pattern p = Pattern.compile(
"([0-9零一二两三四五六七八九十百千万]+)(" + units + ")");
Matcher m = p.matcher(s);
long total = 0;
int found = 0;
while (m.find()) {
found++;
long num = parseNumber(m.group(1));
Long u = UNIT_MS.get(m.group(2));
if (u == null) continue;
total += num * u;
}
return found > 0 ? total : -1;
}
private static long buildDateMillis(int y, int mo, int d) {
Calendar c = Calendar.getInstance();
c.set(y, mo - 1, d, 0, 0, 0);
c.set(Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
/** 解析数字(阿拉伯数字优先,其次中文数字 零-九/十百千万/两/壹贰叁…) */
private static long parseNumber(String s) {
if (s.matches("\\d+")) return Long.parseLong(s);
Map<Character, Long> cn = new HashMap<>();
cn.put('零', 0L); cn.put('〇', 0L);
cn.put('一', 1L); cn.put('壹', 1L);
cn.put('二', 2L); cn.put('贰', 2L); cn.put('两', 2L);
cn.put('三', 3L); cn.put('叁', 3L);
cn.put('四', 4L); cn.put('肆', 4L);
cn.put('五', 5L); cn.put('伍', 5L);
cn.put('六', 6L); cn.put('陆', 6L);
cn.put('七', 7L); cn.put('柒', 7L);
cn.put('八', 8L); cn.put('捌', 8L);
cn.put('九', 9L); cn.put('玖', 9L);
long section = 0, num = 0;
boolean hasNum = false;
for (char ch : s.toCharArray()) {
if (cn.containsKey(ch)) {
num = cn.get(ch);
hasNum = true;
} else if (ch == '十') {
if (!hasNum) num = 1;
section += num * 10; hasNum = false; num = 0;
} else if (ch == '百') {
if (!hasNum) num = 1;
section += num * 100; hasNum = false; num = 0;
} else if (ch == '千') {
if (!hasNum) num = 1;
section += num * 1000; hasNum = false; num = 0;
} else if (ch == '万') {
if (!hasNum) num = 1;
section = (section + num) * 10000; hasNum = false; num = 0;
} else {
num = 0;
}
}
return section + (hasNum ? num : 0);
}
/** 格式化绝对到期时间戳为「yyyy年M月d日 HH:mm」 */
public static String formatExpire(long ms) {
Calendar c = Calendar.getInstance();
c.setTimeInMillis(ms);
return String.format("%d年%d月%d日 %02d:%02d",
c.get(Calendar.YEAR),
c.get(Calendar.MONTH) + 1,
c.get(Calendar.DAY_OF_MONTH),
c.get(Calendar.HOUR_OF_DAY),
c.get(Calendar.MINUTE));
}
}
+127 -1
View File
@@ -382,6 +382,7 @@ public class Main extends JavaPlugin
chatFilter = new ChatFilterManager(this);
chatFilter.loadConfig();
chatFilter.loadIllegalDomains(); // 加载非法域名后缀
chatFilter.loadMutes(); // 启动加载持久化禁言(muted_until 字段)
welcome = new WelcomeManager(this);
// ===== 5.5 Web通信管理器 =====
@@ -1835,6 +1836,20 @@ public class Main extends JavaPlugin
}
}
// ===== 异地登录检测:异步通知PHP比较IP归属并发提醒邮件 =====
if (webManager != null && ip != null
&& !ip.isEmpty()) {
final String alertName = name;
final String alertIp = ip;
new BukkitRunnable() {
@Override
public void run() {
webManager.reportLoginLocation(
alertName, alertIp);
}
}.runTaskAsynchronously(this);
}
// ★ 传送联动:登录阶段自动开启接受传送(基岩版玩家跳过)
if (teleportMgr != null) {
teleportMgr.onPlayerLogin(p);
@@ -4656,6 +4671,67 @@ public class Main extends JavaPlugin
return true;
}
// ===== /tempban 临时封禁 =====
if (cmd.getName().equalsIgnoreCase("tempban")) {
if (sender instanceof Player
&& !isAdmin(sender)) {
sender.sendMessage("§c权限不足");
return true;
}
if (args.length < 2) {
sender.sendMessage(
"§e/tempban <玩家名> <时长> [理由]");
sender.sendMessage(
"§7时长示例: 1秒 / 1s / 1年 / 2026年7月9日 / 253402271999");
return true;
}
String target = args[0];
String durStr = args[1];
StringBuilder reason = new StringBuilder();
for (int i = 2; i < args.length; i++) {
if (reason.length() > 0)
reason.append(" ");
reason.append(args[i]);
}
long expireMs;
try {
expireMs = DurationParser
.parseToExpireMs(durStr);
} catch (IllegalArgumentException ex) {
sender.sendMessage("§c时长格式无法解析: "
+ ex.getMessage());
return true;
}
long durMs = expireMs
- System.currentTimeMillis();
long MIN = 1000L;
long MAX = 10L * 365 * 24 * 3600 * 1000; // 约10年
if (durMs < MIN) {
sender.sendMessage("§c封禁时长至少 1 秒");
return true;
}
if (durMs > MAX) {
sender.sendMessage("§c封禁时长最长 10 年");
return true;
}
String r = reason.length() > 0
? reason.toString()
: "您已被服务器封禁,效期至: "
+ DurationParser.formatExpire(expireMs);
Bukkit.getBanList(
org.bukkit.BanList.Type.NAME).addBan(
target, r,
new java.util.Date(expireMs),
sender.getName());
sender.sendMessage("§a已封禁 " + target
+ " 至 " + DurationParser.formatExpire(expireMs));
org.bukkit.entity.Player tp =
Bukkit.getPlayerExact(target);
if (tp != null)
tp.kickPlayer("§c§l" + r);
return true;
}
String sub = (args.length > 0) ? args[0].toLowerCase() : "";
// ===== 在 onCommand 方法体内,最开头加: =====
@@ -5128,6 +5204,56 @@ public class Main extends JavaPlugin
return true;
}
String cs = args[0].toLowerCase();
if (cs.equals("mute")) {
if (args.length < 3) {
sender.sendMessage(
"§e/chat mute <玩家名> <时长> [理由]");
sender.sendMessage(
"§7时长示例: 1秒 / 1s / 1年 / 2026年7月9日 / 253402271999");
return true;
}
String target = args[1];
String durStr = args[2];
StringBuilder reason = new StringBuilder();
for (int i = 3; i < args.length; i++) {
if (reason.length() > 0)
reason.append(" ");
reason.append(args[i]);
}
long expireMs;
try {
expireMs = DurationParser
.parseToExpireMs(durStr);
} catch (IllegalArgumentException ex) {
sender.sendMessage("§c时长格式无法解析: "
+ ex.getMessage());
return true;
}
long durMs = expireMs
- System.currentTimeMillis();
long MIN = 1000L;
long MAX = 30L * 24 * 3600 * 1000; // 1个月
if (durMs < MIN) {
sender.sendMessage("§c禁言时长至少 1 秒");
return true;
}
if (durMs > MAX) {
sender.sendMessage("§c禁言时长最长 1 个月");
return true;
}
String r = reason.length() > 0
? reason.toString()
: "您已被管理员禁言,效期至:"
+ DurationParser.formatExpire(expireMs);
chatFilter.mutePlayer(target, expireMs, r);
sender.sendMessage("§a已禁言 " + target
+ " 至 " + DurationParser.formatExpire(expireMs));
org.bukkit.entity.Player tp =
Bukkit.getPlayerExact(target);
if (tp != null)
tp.sendMessage("§c§l" + r);
return true;
}
if (cs.equals("reload")) {
chatFilter.loadConfig();
sender.sendMessage("§a聊天配置已重载");
@@ -6856,7 +6982,7 @@ public class Main extends JavaPlugin
list.addAll(Arrays.asList(
"reload", "add", "remove",
"addplayer", "takeplayer",
"unmute", "reset"));
"mute", "unmute", "reset"));
}
if (cmd.getName().equalsIgnoreCase("cypay")) {
return cypayCommand.onTabComplete(sender, cmd, label, args);
+91 -2
View File
@@ -110,6 +110,14 @@ public class PVPArenaManager implements Listener {
private final Map<String, Integer> pvpKills = new ConcurrentHashMap<>();
private final Map<String, Integer> pvpDeaths = new ConcurrentHashMap<>();
// 连杀播报(移植自 PVPManager,全服播报)
private final Map<String, PVPManager.KillSession>
arenaKillSessions =
new ConcurrentHashMap<>();
private final Set<String> arenaTripleAnnounced =
ConcurrentHashMap.newKeySet();
private static final String ARENA_LABEL = "PVP竞技场";
// 记分板条目缓存:玩家名 -> 侧边栏条目字符串(用于精确清除)
private final Map<String, String> scoreEntries = new ConcurrentHashMap<>();
@@ -1094,8 +1102,54 @@ public class PVPArenaManager implements Listener {
// paper-api 1.21.4: getKiller() 已移除,改用 DamageSource.getCausingEntity()
org.bukkit.entity.Entity killer = event.getDamageSource().getCausingEntity();
if (killer instanceof Player && inPVPArena.contains(killer.getName())) {
pvpKills.put(killer.getName(), pvpKills.getOrDefault(killer.getName(), 0) + 1);
((Player) killer).sendMessage("\u00a7a\u00a7l击杀 " + player.getName() + "\u00a7a 当前击杀: " + pvpKills.get(killer.getName()));
String kName = killer.getName();
String vName = player.getName();
// 总榜击杀数
pvpKills.put(kName, pvpKills.getOrDefault(kName, 0) + 1);
// 击杀者连杀会话
PVPManager.KillSession ks = arenaKillSessions.get(kName);
if (ks == null
|| System.currentTimeMillis() - ks.lastKill
> 5 * 60 * 1000) {
arenaTripleAnnounced.remove(kName);
PVPManager.KillSession ns =
new PVPManager.KillSession();
ns.region = ARENA_LABEL;
ns.count = 1;
ns.firstKill = System.currentTimeMillis();
ns.lastKill = System.currentTimeMillis();
arenaKillSessions.put(kName, ns);
} else {
ks.count++;
ks.lastKill = System.currentTimeMillis();
}
// 终结检测:受害者连杀 >= 3 被终结
PVPManager.KillSession victimSession =
arenaKillSessions.get(vName);
if (victimSession != null
&& victimSession.count >= 3) {
String endMsg = "§b§l" + kName + " §e终结了 "
+ vName + " §e的 " + victimSession.count
+ "连杀!§a恭喜终结者 " + kName
+ "§e!§7快来挑战!";
arenaKillSessions.remove(vName);
broadcastWithSound(endMsg,
org.bukkit.Sound.ENTITY_ENDER_DRAGON_GROWL);
}
// 无论如何,死者连杀清零
arenaKillSessions.remove(vName);
// 多杀通报(全服播报)
PVPManager.KillSession killerSession =
arenaKillSessions.get(kName);
if (killerSession != null) {
checkArenaMultiKill((Player) killer,
killerSession);
}
((Player) killer).sendMessage("\u00a7a\u00a7l击杀 " + vName + "\u00a7a 当前击杀: " + pvpKills.get(kName));
}
pvpDeaths.put(player.getName(), pvpDeaths.getOrDefault(player.getName(), 0) + 1);
refreshPVPScoreboard();
@@ -1119,6 +1173,41 @@ public class PVPArenaManager implements Listener {
}.runTaskLater(plugin, 20L); // 1秒后
}
// ===== 全服击杀播报(移植自 PVPManager) =====
private void broadcastWithSound(String msg,
org.bukkit.Sound sound) {
Bukkit.broadcastMessage(msg);
for (Player p : Bukkit.getOnlinePlayers()) {
p.playSound(p.getLocation(), sound,
1.0f, 1.0f);
}
}
private void checkArenaMultiKill(Player killer,
PVPManager.KillSession session) {
int count = session.count;
String kName = killer.getName();
String msg = null;
if (count >= 5) {
msg = "§c§l" + kName + " §e在「" + ARENA_LABEL
+ "」达成 " + count + "连杀!§7§l快来挑战!";
} else if (count == 3) {
if (arenaTripleAnnounced.add(kName)) {
msg = "§6§l三杀!" + kName
+ " 势不可挡!§7快来挑战!";
}
}
if (msg != null) {
if (count >= 5) {
broadcastWithSound(msg,
org.bukkit.Sound.ENTITY_ENDER_DRAGON_GROWL);
} else {
broadcastWithSound(msg,
org.bukkit.Sound.BLOCK_END_PORTAL_SPAWN);
}
}
}
/**
* 玩家加入事件 — 如果上次在PVP中断线,还原背包
*/
+56
View File
@@ -3908,6 +3908,39 @@ public class WebManager {
}
break;
}
case "freeze": {
// ★ 玩家主动冻结账号(异地登录邮件触发):Bukkit 原生封禁
String target = targetName;
if (!target.isEmpty()) {
String reason = "§c§l您的账号已被临时冻结(疑似被盗)\n§7请到网页修改密码后自动解冻";
Bukkit.getBanList(
org.bukkit.BanList.Type.NAME)
.addBan(target, reason,
null, "security");
org.bukkit.entity.Player fp =
Bukkit.getPlayerExact(target);
if (fp != null)
fp.kickPlayer(reason);
plugin.getLogger().info(
"[安全] 已按玩家请求冻结账号: "
+ target);
applied = true;
}
break;
}
case "unfreeze": {
// ★ 玩家改密后解冻:解除 Bukkit 原生封禁
String target = targetName;
if (!target.isEmpty()) {
Bukkit.getBanList(
org.bukkit.BanList.Type.NAME)
.pardon(target);
plugin.getLogger().info(
"[安全] 已解冻账号: " + target);
applied = true;
}
break;
}
case "group_buy": {
// ★ 玩家端付费加入用户组:PHP写入pending → Java拉取执行
String buyGroup = String.valueOf(changeData.getOrDefault("group_name", ""));
@@ -5289,6 +5322,29 @@ public class WebManager {
return secretKey;
}
/**
* 异步通知 PHP:玩家加入服务器,触发异地登录检测与提醒邮件。
* PHP 端比较 IP 归属并决定是否发邮件(含冻结/改密链接)。
*/
public void reportLoginLocation(String name, String ip) {
if (!enabled) return;
try {
String url = webBaseUrl + "/api/security_alert.php"
+ "?action=login_location_alert"
+ "&secret=" + java.net.URLEncoder
.encode(secretKey, "UTF-8")
+ "&name=" + java.net.URLEncoder
.encode(name, "UTF-8")
+ "&ip=" + java.net.URLEncoder
.encode(ip, "UTF-8");
doGet(url);
} catch (Exception e) {
plugin.getLogger().warning(
"[安全] 通知PHP异地登录检测失败: "
+ e.getMessage());
}
}
// ==================== Web登录轮询 ====================
/**
+4
View File
@@ -153,3 +153,7 @@ commands:
back:
description: 快速返回最近记录点
usage: /back
tempban:
description: 临时封禁玩家
usage: /tempban <玩家名> <时长> [理由]