Compare commits

...
26 Commits
Author SHA1 Message Date
youpaishidifu a2c92d9150 第57批续11:PVP弃用API修复(Paper 26.x)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 3m5s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- GameRule.DO_DAYLIGHT_CYCLE → GameRules.ADVANCE_TIME
- GameRule.DO_WEATHER_CYCLE → GameRules.ADVANCE_WEATHER
- GameRule.DO_MOB_SPAWNING → GameRules.SPAWN_MONSTERS
- GameRule.KEEP_INVENTORY → GameRules.KEEP_INVENTORY
- AttributeModifier(String,...) → AttributeModifier(NamespacedKey,...)
2026-10-26 20:11:53 +08:00
youpaishidifu 325d363f4b 第57批续10:webbind绑邮箱密码验证接入统一风控+空行清理
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- Main.java: /webbind approve 密码验证从独立webbindPwdFails内存计数器
  接入RiskControlManager(与/login完全一致):密码错5次→Bukkit真实封禁
  5分钟+踢出+报警邮件+重连拒绝+到期自动解封
- Main.java: 删除已无引用的webbindPwdFails字段
- RiskControlManager.java: 按8a693e规则清理方法内部空行(11行)
2026-10-26 19:20:05 +08:00
youpaishidifu 74e2c60d60 第57批续7: Java端验证密码后直接回传pwd_verified=1,PHP不再重复验密;简化绑定流程
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m31s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-26 16:27:17 +08:00
youpaishidifu ad17f9ccba 第57批续6: 修复3个bug
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m31s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2. /login成功后重新推送weblogin token(onJoin推送的token login_verified=0,Web端不认)
2026-10-26 14:58:37 +08:00
youpaishidifu b65562850f 第57批续5: 修复4个bug
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m14s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
1. onJoin封禁缺口通知改可点击Component(Bug#1)
2. webbind approve改聊天框输入密码+拒绝选项(Bug#2)
3. PHP acctBindEmailFinish rejected状态简化为单关闭按钮(Bug#3)
4. drawer-card毛玻璃效果修正(Bug#4)
2026-10-26 13:10:41 +08:00
youpaishidifu 956ee0e940 第57批续4:autoGenerateWebLoginToken改用isEnabled()实时读文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m24s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-26 12:00:05 +08:00
youpaishidifu f7f47e6178 第57批续4:confirm/cancel ban移到通用confirm/cancel之前 + 全文件\u00a7替换为§ 2026-10-26 11:36:54 +08:00
youpaishidifu 7b62075dc7 第57批续3修正:/del ban拦截移到del分支内 + 管理员通知改为可点击消息
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-26 11:10:45 +08:00
youpaishidifu d7d51cbb6b 第57批续3:登录推送补全+ban命令拦截+管理员广播
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m29s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- LoginManager.finishPasswordLogin 补 pushLoginVerified(/login 统一收口推送 login_verified=1)
- Main: /delete ban 拦截(ban 不是玩家账号名);confirm/cancel ban 加 hasBanPending 检查
- WebManager: broadcastToAdmins 方法 + enterBanPendingMode 缺口较大时给在线管理员发 CLI 通知
2026-10-26 10:49:22 +08:00
youpaishidifu 6eaa883849 第57批续2:时间戳文件两份问题修复
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- readTimestampFile:多份时间戳文件取最小值(用户手动放的固定值 < 插件启动创建的当前秒值)
- ensureTimestampFile:目录里已有任意时间戳文件就跳过,不重复创建
2026-10-26 09:53:03 +08:00
youpaishidifu 8a693e6417 第57批续:封禁播报时间戳文件+空行清理+补丁去重
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 3m0s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- BanBroadcastManager:resolveStartCutoff 优先读数据目录10位时间戳文件,无则回退目录创建时间+180天保护;启动时自动创建时间戳文件
- WebManager:删除2075行空行+激进清理(方法内部空行全删,块注释/方法间空行保留);bans_intercept/post_scan去重
- Main/LoginManager:去重重复插入的confirm/cancel ban块和pushLoginVerified块
2026-10-26 00:10:06 +08:00
youpaishidifu 8718dc6e87 第57批修复:补上post_scan+bans_intercept(v3静默失败的CRLF不匹配补丁)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m55s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-25 22:38:33 +08:00
youpaishidifu 51f56b9e18 第57批:封禁数据只读+缺口待确认+登录状态推送login_verified=1
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m13s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-25 21:28:39 +08:00
youpaishidifu 0b3a8debdd 第56批:webbind批准Java端验密码(5次风控)+注册token附带邮箱后缀配置
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m37s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-25 18:40:35 +08:00
youpaishidifu 340726205c 第55批:登录状态主权修复 - token生成时按实际登录状态推login_verified(未登录推0让PHP清除旧记录)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m30s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-25 17:37:15 +08:00
youpaishidifu ba73ebfdb2 第54批:登录验证主权反转 - 消息文案修正
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m25s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
根因:LoginManager.java:309 '正在核对网页端最新密码…' 暗示 PHP 有更高权威,
用户感知「主权搞反了」。

架构现状(已确认无需改动):
- PHP 不验证密码(sync.php verify_web_password 已禁用)
- Java 本地 checkPasswordWithFallback 为主
- Java 拉回 PHP 凭证入库主密码(applyPulledRow → updatePassword)
- Java 回传结果给 PHP(sendWebLoginResult → complete_web_login_request)

修复:
1. LoginManager.java: 消息文案改为中性「正在同步账号数据…」
2. WebManager.java:6550: 冻结解冻消息改为「请在游戏内执行 /sdf1_login pw」
3. 注释更新:明确「拉回PHP凭证入库为主密码」「以Java本地login.db为准」

验证:
- 括号配平 PASS(final=0, min=0)
- mvn clean compile BUILD SUCCESS (16.9s)
- 行尾:LoginManager LF, WebManager CRLF
2026-10-25 16:37:52 +08:00
youpaishidifu 5e9bd5f6bf 第53批:网页绑邮箱游戏内批准流——定时器A链尾轮询+clickable批准通知+/webbind命令
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-23 23:01:42 +08:00
youpaishidifu 474b912afe 第52批: 修复\/JSON转义脏值四层防护
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m34s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- 解析根修: addParsedEntry/parseJsonArrayItem清洗链补\/还原(置于\\还原之前); extractJsonString扫描跳过转义序列并按JSON规则反转义
- 写库兜底: createUser/updatePassword入参b64Clean; applyPulledRow credentials的hash/salt/temp写库前白名单清洗
- 存量迁移: init启动时REPLACE char92清洗users表password_hash/password_salt/temp_password(仿一次性自愈范式,WHERE instr条件幂等)
- 读取容错: checkPassword/checkPasswordWithFallback/checkPasswordForChange比对前双侧b64Clean
- 根因: PHP json_encode把base64中的/转义成\/,历史解析层漏还原写进库,hash脏值致游戏端+网页端密码恒错
2026-10-23 19:21:14 +08:00
youpaishidifu 5b705d0af2 第49轮诊断:收回写库前后抓新旧hash/salt完整值入日志,抓对账ping-pong现行
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m33s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-23 16:04:22 +08:00
youpaishidifu 7525b8f6b5 第48批(1/2):修复对账指纹未落盘致网页改密被反向覆盖;登录失败自动对账重试;绑定2FA后游戏登录强制密码+动态码两关
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m24s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- saveAlignSeen 自杀式节流修复:原节流条件 now-dirtyAt<5000 恒成立(调用点先设 dirtyAt 再调用)
  → align_seen.json 从未落盘 → 重启后 seen 全空 → 网页新密码被游戏旧密码反向覆盖;
  改为以「上次成功写入」alignSeenLastWrite 为基准节流
- /login 密码不匹配:15秒/人限流 → 异步 pushWebLoginCredentials(含 alignGate 拉回) →
  主线程自动重校验;重试仍败走正常风控计数(解决网页改密后游戏内密码对不上)
- 已绑定2FA(twofa_enabled=1)的玩家密码通过后不再直接放行:requireTwoFaIfEnabled
  转入双验证第二步等 /2fa code(此前仅 IP 变更才触发两关,同IP登录第一层过关直接放行)
2026-10-23 11:27:38 +08:00
youpaishidifu 2090939e87 第47批:2FA绑定状态与网页端双向同步——users表新增twofa_bound_at;WebManager新增pull_state/push_state;TwoFactorManager同步合并逻辑(导入/新者胜/解绑传播)+4写入点推送+进服静默同步;Main进服挂点
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 6m8s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-22 23:25:01 +08:00
youpaishidifu 14c288a77c 第45批:垃圾站轮次播报——保留仅1轮时不提示本轮结束时倾倒垃圾(仅保留>1才提示)
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-22 19:18:53 +08:00
youpaishidifu c92e79160c 合并远端 README 提交 a21e95d(仅 README) 2026-10-22 16:27:21 +08:00
youpaishidifu a21e95df68 docs: 完善 README 使用手册(3.11) 2026-10-22 16:23:16 +08:00
youpaishidifu 0b5422395c docs: 完善 README 使用手册(3.11)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 5m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-22 16:22:35 +08:00
youpaishidifu b7b564a091 3.11内测版:plugin.yml版本号升级
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m35s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-22 15:52:23 +08:00
12 changed files with 14000 additions and 12242 deletions
+856 -341
View File
File diff suppressed because it is too large Load Diff
@@ -146,6 +146,7 @@ public class BanBroadcastManager implements Listener {
} catch (Exception ignored) {}
long cut0 = resolveStartCutoff();
File bf0 = bannedPlayersFile();
ensureTimestampFile();
plugin.getLogger().info("[封禁播报] 已启动,模式=" + modeName()
+ " 间隔=" + minInterval + "~" + maxInterval + "s 每页" + pageSize + "条"
+ " 起点=" + (cut0 > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cut0)) : "不限")
@@ -497,37 +498,95 @@ public class BanBroadcastManager implements Listener {
if (!startCutoffAuto) return startCutoffMs;
if (autoCutoffMs < 0) {
autoCutoffMs = 0;
long folderCreated = 0;
try {
File df = plugin.getDataFolder();
if (df != null && df.exists()) {
long t = java.nio.file.Files.readAttributes(df.toPath(),
java.nio.file.attribute.BasicFileAttributes.class)
.creationTime().toMillis();
if (t > 0) folderCreated = t;
long tsFile = readTimestampFile();
if (tsFile > 0) {
autoCutoffMs = tsFile;
} else {
long folderCreated = 0;
try {
File df = plugin.getDataFolder();
if (df != null && df.exists()) {
long t = java.nio.file.Files.readAttributes(df.toPath(),
java.nio.file.attribute.BasicFileAttributes.class)
.creationTime().toMillis();
if (t > 0) folderCreated = t;
}
} catch (Exception e) {
warn("读取插件数据目录创建时间失败,本次不过滤历史封禁: "
+ e.getMessage());
}
} catch (Exception e) {
warn("读取插件数据目录创建时间失败,本次不过滤历史封禁: "
+ e.getMessage());
}
if (folderCreated > 0) {
long cap = System.currentTimeMillis() - MAX_AUTO_CUTOFF_WINDOW_MS;
autoCutoffMs = Math.min(folderCreated, cap);
if (folderCreated > cap) {
warn("插件数据目录创建时间 " + fmtCutoff(folderCreated)
+ " 晚于180天前(目录可能被重建/迁移),"
+ "自动起点回退为 " + fmtCutoff(cap)
+ ",近半年内的生效封禁照常播报"
+ "(可在 ban/规则配置.md 手动填「播报起点」精确控制)");
if (folderCreated > 0) {
long cap = System.currentTimeMillis() - MAX_AUTO_CUTOFF_WINDOW_MS;
autoCutoffMs = Math.min(folderCreated, cap);
if (folderCreated > cap) {
warn("插件数据目录创建时间 " + fmtCutoff(folderCreated)
+ " 晚于180天前(目录可能被重建/迁移),"
+ "自动起点回退为 " + fmtCutoff(cap)
+ ",近半年内的生效封禁照常播报"
+ "(可在 ban/规则配置.md 手动填「播报起点」精确控制)");
}
}
if (autoCutoffMs <= 0) {
warn("取不到插件数据目录创建时间,历史封禁过滤已关闭"
+ "(可在 ban/规则配置.md 里手动填「播报起点」锁定时间)");
}
}
if (autoCutoffMs <= 0) {
warn("取不到插件数据目录创建时间,历史封禁过滤已关闭"
+ "(可在 ban/规则配置.md 里手动填「播报起点」锁定时间)");
}
}
return autoCutoffMs;
}
/** ★ 第57批:读插件数据目录中的时间戳文件(10位数字无后缀)
* 优先级:用户配置 > 时间戳文件 > 插件数据目录创建时间
*/
private long readTimestampFile() {
try {
File df = plugin.getDataFolder();
if (df == null) return 0;
File[] files = df.listFiles();
if (files == null) return 0;
// ★ 第57批续:多份时间戳文件取最小值(用户手动放的固定值 < 插件启动创建的当前秒值)
long minTs = -1;
for (File f : files) {
String name = f.getName();
if (name.length() == 10 && name.chars().allMatch(Character::isDigit)) {
long ts = Long.parseLong(name);
if (ts > 0 && (minTs < 0 || ts < minTs)) minTs = ts;
}
}
if (minTs > 0) return minTs * 1000L;
} catch (Exception e) {
warn("读时间戳文件失败: " + e.getMessage());
}
return 0;
}
/** ★ 第57批:插件启动时创建时间戳文件(如果不存在)
* 文件名 = 当前时间戳(秒),内容为空
*/
private void ensureTimestampFile() {
try {
File df = plugin.getDataFolder();
if (df == null || !df.exists()) return;
// ★ 第57批续:目录里已有任意时间戳文件就跳过,不重复创建
File[] files = df.listFiles();
if (files != null) {
for (File f : files) {
String name = f.getName();
if (name.length() == 10 && name.chars().allMatch(Character::isDigit)) {
return; // 已有时间戳文件,不创建
}
}
}
long tsSec = System.currentTimeMillis() / 1000L;
File tsFile = new File(df, String.valueOf(tsSec));
if (!tsFile.exists()) {
java.nio.file.Files.createFile(tsFile.toPath());
plugin.getLogger().info("[封禁播报] 已创建时间戳文件: " + tsFile.getName());
}
} catch (Exception e) {
warn("创建时间戳文件失败: " + e.getMessage());
}
}
/** 28批:自动播报起点最多回溯 180 天(约半年)——数据目录重建时起点不得过新 */
private static final long MAX_AUTO_CUTOFF_WINDOW_MS = 180L * 24 * 3600 * 1000;
+38 -3
View File
@@ -133,6 +133,10 @@ public class DatabaseManager {
"TEXT DEFAULT ''");
safeAdd(st, "twofa_pending_at",
"INTEGER DEFAULT 0");
// ★ 第47批:绑定时间(秒)——与网页端 web_account_2fa.bound_at 对齐,
// 双向同步冲突时"取时间新者"用
safeAdd(st, "twofa_bound_at",
"INTEGER DEFAULT 0");
// ===== 2FA 恢复代码(SHA-256 逗号连接存库,明文只发邮件,一次性有效)=====
safeAdd(st, "twofa_recovery_codes",
"TEXT DEFAULT ''");
@@ -146,6 +150,20 @@ public class DatabaseManager {
} catch (SQLException ignored) {
}
// ★ 第52批:存量脏凭证一次性清洗——PHP json_encode 把 base64 中的
// / 转义成 \/,历史解析层漏还原曾把反斜杠写进库(hash 脏 → 密码恒错)。
// base64 合法字符永不含反斜杠(char92),删除即还原正确值;WHERE 带
// instr 条件,无脏值时为近似 no-op,可每次启动安全重跑。
try {
st.executeUpdate("UPDATE users SET password_hash=REPLACE(password_hash, char(92), '') "
+ "WHERE instr(password_hash, char(92))>0");
st.executeUpdate("UPDATE users SET password_salt=REPLACE(password_salt, char(92), '') "
+ "WHERE instr(password_salt, char(92))>0");
st.executeUpdate("UPDATE users SET temp_password=REPLACE(temp_password, char(92), '') "
+ "WHERE instr(temp_password, char(92))>0");
} catch (SQLException ignored) {
}
// ===== 背包备份表:安全迁移 =====
// 检查旧表是否存在(用 inventory_data 列判断)
boolean hasOldTable = false;
@@ -744,8 +762,22 @@ public class DatabaseManager {
}
}
/**
* ★ 第52批:base64 凭证白名单清洗。
* PHP json_encode 默认把 base64 中的 / 转义成 \/,历史解析层漏还原
* 会把反斜杠写进库(hash 脏值 → 游戏/网页两端密码恒错)。
* base64 合法字符永不含反斜杠,先还原 \/ 再按白名单清洗即得正确值;
* 写库与比对两侧同款清洗,保证对称。
*/
public static String b64Clean(String v) {
if (v == null) return null;
return v.replace("\\/", "/").replaceAll("[^A-Za-z0-9+/=]", "");
}
public void createUser(String name, String hash,
String salt) {
hash = b64Clean(hash);
salt = b64Clean(salt);
try {
PreparedStatement ps = db.prepareStatement(
"INSERT INTO users "
@@ -770,6 +802,8 @@ public class DatabaseManager {
* 更新玩家密码凭证(salt + hash),用于Web重新注册时同步
*/
public void updatePassword(String name, String hash, String salt) {
hash = b64Clean(hash);
salt = b64Clean(salt);
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE users SET password_hash = ?, password_salt = ? WHERE player_name = ?");
@@ -831,7 +865,8 @@ public class DatabaseManager {
rs.getString("password_hash");
rs.close();
ps.close();
return hash.equals(mainHash);
// ★ 第52批:比对前双侧清洗(存量脏 hash 容错,见 b64Clean)
return b64Clean(hash).equals(b64Clean(mainHash));
} else {
rs.close();
ps.close();
@@ -870,7 +905,7 @@ public class DatabaseManager {
ps.close();
if (tempHash != null
&& !tempHash.isEmpty()
&& mainHash.equals(tempHash)
&& b64Clean(mainHash).equals(b64Clean(tempHash))
&& System.currentTimeMillis() < expire
&& used != 1) {
return "temp";
@@ -919,7 +954,7 @@ public class DatabaseManager {
ps.close();
if (tempHash != null
&& !tempHash.isEmpty()
&& hash.equals(tempHash)
&& b64Clean(hash).equals(b64Clean(tempHash))
&& used != 1) {
return "temp";
}
+15 -8
View File
@@ -646,14 +646,21 @@ public class GarbageManager implements Listener {
// r == n 即「本轮结束时倾倒垃圾」(下一轮才真正倒)。
final int nextRound = roundNow + 1; // 清理后开启的新一轮
final int r = ((nextRound - 1) % n) + 1;
if (r < n) {
broadcast("§7[垃圾站] §f当前是第 §e" + r
+ " §f轮,第 §e" + n
+ " §f轮倒垃圾,还剩 §e" + (n - r)
+ " §f轮倾倒垃圾");
} else {
broadcast("§7[垃圾站] §f当前是第 §e" + n
+ " §f轮,本轮结束时倾倒垃圾");
// ★ 第45批:保留只有1轮(n==1)时
// 每一轮都是倒垃圾轮,「本轮结束时
// 倾倒垃圾」无增量信息 → 不播报;
// 仅保留>1才提示轮次进度(用户
// 2026-10-22 截图反馈)。
if (n > 1) {
if (r < n) {
broadcast("§7[垃圾站] §f当前是第 §e" + r
+ " §f轮,第 §e" + n
+ " §f轮倒垃圾,还剩 §e" + (n - r)
+ " §f轮倾倒垃圾");
} else {
broadcast("§7[垃圾站] §f当前是第 §e" + n
+ " §f轮,本轮结束时倾倒垃圾");
}
}
// 打印下次清理时间
@@ -12,6 +12,13 @@ public class LoginManager {
this.plugin = plugin;
}
/** ★第48批#244:/login 失败自动对账重试的每人限流(值=上次对账触发时刻) */
private final java.util.Map<String, Long> pwSyncRetryAt =
new java.util.concurrent.ConcurrentHashMap<>();
/** ★第48批#244:正在对账重试中的玩家(防递归) */
private final java.util.Set<String> pwSyncRetrying =
java.util.concurrent.ConcurrentHashMap.newKeySet();
public boolean handleRegister(Player p,
String[] args) {
String name = p.getName();
@@ -69,6 +76,10 @@ public class LoginManager {
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
// ★ 第57批:/login 成功后推送 login_verified=1 给 PHP
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
plugin.webManager.pushLoginVerified(name);
});
}
p.setAllowFlight(false);
p.setFlying(false);
@@ -191,6 +202,8 @@ public class LoginManager {
if (matchMain) {
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
if (markDualStep1(p, false)) return true;
// ★第48批#249:已绑定2FA → 密码过后强制第二关 /2fa code
if (requireTwoFaIfEnabled(p, false)) return true;
finishPasswordLogin(p, false);
return true;
}
@@ -202,11 +215,17 @@ public class LoginManager {
if (pwdResult != null) {
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
if (markDualStep1(p, true)) return true;
// ★第48批#249:已绑定2FA → 密码过后强制第二关 /2fa code
if (requireTwoFaIfEnabled(p, true)) return true;
finishPasswordLogin(p, true);
return true;
}
// ★第54批:密码不匹配 → 先同步账号数据(拉回PHP侧凭证入库为主密码),
// 15秒/人限流;同步完成后主线程自动重校验(以Java本地login.db为准),仍未过才计失败次数
if (maybeRetryAfterWebSync(p, pwd)) return true;
// ★ 密码错误,记录风控
int remaining = plugin.getRiskControl()
.recordFailAttempt(p);
@@ -251,6 +270,67 @@ public class LoginManager {
return true;
}
/**
* ★第48批#249:已绑定 2FA(twofa_enabled=1)的玩家,密码通过后不直接放行,
* 转入「密码 + 2FA」双验证第二步(等 /2fa code)。
* 仅在该玩家当前不在双验证流程时生效(IP风控已 mark 的走 markDualStep1)。
*
* @return true = 已进入双验证流程,本轮 /login 到此为止
*/
private boolean requireTwoFaIfEnabled(Player p, boolean tempPassword) {
TwoFactorManager twofa = plugin.getTwofa();
String name = p.getName();
if (twofa == null) return false;
if (twofa.isDualVerify(p)) return false; // IP风控路径已处理
if (!plugin.getDb().userExists(name)) return false;
if (!twofa.isEnabled(name)) return false; // 未绑定2FA:维持原单步登录
// 密码这一步确实对了 → 建立双验证状态并记录进度(不解冻、不恢复背包)
twofa.markDualVerify(p, plugin.getPlayerIP(p));
twofa.onDualPasswordPassed(p, tempPassword);
// 重置「密码错误次数」风控(密码对了,但仍未完成登录)
plugin.getRiskControl().onLoginSuccess(name);
p.sendMessage("§a§l[双重验证] §a第一步密码验证通过(尚未解冻)");
p.sendMessage("§7第二步: 执行 §f/2fa code <动态码|恢复代码>");
p.sendMessage("§c两步全部通过后才会解冻并恢复背包");
return true;
}
/**
* ★第54批:密码校验失败时先同步账号数据(拉回PHP侧凭证入库)再自动重试一次。
* 15秒/人限流;重试仍失败走正常风控计数。
*
* @return true = 本轮 /login 已交给异步对账流程处理(调用方直接 return)
*/
private boolean maybeRetryAfterWebSync(Player p, String pwd) {
if (plugin.webManager == null) return false;
String name = p.getName();
if (pwSyncRetrying.contains(name)) return false; // 重试轮失败:不再触发
long now = System.currentTimeMillis();
Long last = pwSyncRetryAt.get(name);
if (last != null && now - last < 15000L) return false; // 15秒/人限流
pwSyncRetryAt.put(name, now);
pwSyncRetrying.add(name);
p.sendMessage("§e§l[登录] §e正在同步账号数据…");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.webManager.pushWebLoginCredentials(); // 含 alignGate 拉回
} catch (Throwable t) {
plugin.getLogger().warning("[登录] 对账重试同步异常: " + t.getMessage());
}
Bukkit.getScheduler().runTask(plugin, () -> {
try {
Player online = Bukkit.getPlayerExact(name);
if (online == null || !online.isOnline()) return;
// 主线程重新走一次完整登录校验(含2FA/风控前置)
handleLogin(online, new String[]{pwd});
} finally {
pwSyncRetrying.remove(name);
}
});
});
return true;
}
/**
* 密码登录成功的统一收口:解冻 + 恢复背包 + 记录登录信息。
* 调用点:/login 主密码、/login 临时密码、IP风控双验证第二步通过后。
@@ -263,6 +343,10 @@ public class LoginManager {
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
// ★ 第57批续3:/login 成功后推送 login_verified=1 给 PHP(统一收口)
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
plugin.webManager.pushLoginVerified(name);
});
}
plugin.getDb().setLoggedIn(name, true);
if (!tempPassword) {
+164
View File
@@ -141,6 +141,9 @@ public class Main extends JavaPlugin
private final Map<String, Long> ipAutoLogin =
new HashMap<>();
private static final long IP_SKIP_MS = 300000L;
// ★ 第56批:/webbind 批准密码验证失败计数(玩家名 -> 失败次数)
// ★ 第57批续5: /webbind approve 改聊天框输入密码(玩家名 -> 请求ID)
private final ConcurrentHashMap<String, String> pendingWebbindApprove = new ConcurrentHashMap<>();
private SalesStatsManager salesStats;
private final Map<UUID, Long> lastActivity =
@@ -2983,6 +2986,25 @@ public class Main extends JavaPlugin
Player p = e.getPlayer();
String name = p.getName();
String ip = getPlayerIP(p);
// ★ 第57批:插件管理员登录 → CLI 通知待确认的封禁缺口
if (webManager != null && webManager.hasBanPending() && isAdmin(p)) {
p.sendMessage(net.kyori.adventure.text.Component.text()
.append(net.kyori.adventure.text.Component.text("§c§l[封禁-缺口] §f检测到 "))
.append(net.kyori.adventure.text.Component.text(String.valueOf(webManager.getBanPendingCount()))
.color(net.kyori.adventure.text.format.NamedTextColor.RED))
.append(net.kyori.adventure.text.Component.text(" 条封禁数据缺口待确认"))
.build());
p.sendMessage(net.kyori.adventure.text.Component.text()
.append(net.kyori.adventure.text.Component.text("§a[点击拉回封禁] §7").clickEvent(
net.kyori.adventure.text.event.ClickEvent.runCommand("/sdf1_login confirm ban")))
.append(net.kyori.adventure.text.Component.text("§7= /sdf1_login confirm ban"))
.build());
p.sendMessage(net.kyori.adventure.text.Component.text()
.append(net.kyori.adventure.text.Component.text("§c[点击确认解封] §7").clickEvent(
net.kyori.adventure.text.event.ClickEvent.runCommand("/sdf1_login cancel ban")))
.append(net.kyori.adventure.text.Component.text("§7= /sdf1_login cancel ban"))
.build());
}
// ===== 公告计分板(第35批)=====
// 延后 1 秒:避免与登录/权限流程抢计分板
@@ -3181,6 +3203,10 @@ public class Main extends JavaPlugin
if (!db.userExists(name)) {
webManager.syncUserOnJoin(name);
}
// 第47批:进服静默同步网页端 2FA 绑定状态(登录风控 isEnabled 读到同步后结果)
if (twofa != null) {
twofa.syncOnJoin(name);
}
}
// ★ 同IP判断:只作为开启自动登录验证的钥匙,不作为自动登录依据
@@ -6231,6 +6257,57 @@ public class Main extends JavaPlugin
return;
}
// ★ 第57批续5: /webbind approve 密码输入拦截
String pendingBindReq = pendingWebbindApprove.get(p.getName());
if (pendingBindReq != null) {
e.setCancelled(true);
String input = msg.trim();
// 拒绝选项
if (input.equalsIgnoreCase("拒绝") || input.equalsIgnoreCase("reject")) {
pendingWebbindApprove.remove(p.getName());
p.sendMessage("§e已拒绝,网页端直接返回");
webManager.submitWebEmailBindResult(pendingBindReq, p.getName(),
"reject", "", status -> {});
return;
}
// ★ 第57批续10:接入统一风控(RiskControlManager),与 /login 完全一致
// 密码错5次 → Bukkit真实封禁5分钟 + 踢出 + 报警邮件(不再仅提示)
if (riskControl != null && riskControl.isBanned(p.getName())) {
int rem2 = riskControl.getBanRemainingSeconds(p.getName());
pendingWebbindApprove.remove(p.getName());
p.sendMessage("§c§l[风控] §f密码错误次数过多,账号已被临时封禁 §e"
+ rem2 + " §f秒");
return;
}
String pwdSalt2 = (String) db.getField(p.getName(), "password_salt");
String pwdHash2 = PasswordUtils.hash(input, pwdSalt2);
String pwdResult2 = db.checkPasswordWithFallback(p.getName(), pwdHash2);
if (pwdResult2 == null) {
int remaining2 = riskControl != null
? riskControl.recordFailAttempt(p) : 4;
if (remaining2 < 0) {
// 已达阈值:triggerBan 已真实封禁并踢出
return;
}
p.sendMessage("§c密码错误");
if (remaining2 > 0) {
p.sendMessage("§c§l[风控] §f剩余 §e" + remaining2 + " §f次尝试机会");
} else {
p.sendMessage("§c§l[风控] §f连续5次失败,已被临时封禁");
pendingWebbindApprove.remove(p.getName());
}
return;
}
// 验证通过,重置风控计数
if (riskControl != null) riskControl.onLoginSuccess(p.getName());
String reqId2 = pendingBindReq;
pendingWebbindApprove.remove(p.getName());
p.sendMessage("§e正在提交批准…");
webManager.submitWebEmailBindResult(reqId2, p.getName(),
"approve", "", true, status -> {});
return;
}
// ★ 2FA 解绑确认输入拦截(30秒 confirm 窗口,
// 输入 confirm=解绑,其它任意内容/超时=取消)
if (twofa != null && twofa.isRemoveConfirming(p)) {
@@ -9549,6 +9626,10 @@ public class Main extends JavaPlugin
// ★ 未绑定邮箱:登录成功后提示
if (loggedIn.contains(p.getName())) {
remindEmailBindIfNeeded(p);
// ★ 第57批续6:/login成功后重新推送weblogin token(onJoin时推送的token login_verified=0,Web端不认)
if (webManager != null) {
webManager.autoGenerateWebLoginToken(p);
}
}
} catch (Exception e) {
@@ -9606,6 +9687,65 @@ public class Main extends JavaPlugin
return true;
}
// ★ 第53批:网页端绑邮箱 · 游戏内批准/拒绝(/webbind approve|reject <id>)
if (cmdName.equals("webbind")) {
if (!(sender instanceof Player)) {
sender.sendMessage("§c仅玩家可用");
return true;
}
final Player wp = (Player) sender;
if (isFrozen(wp)) {
wp.sendMessage("§c§l[登录] §f你还未登录,无法使用该命令。请先使用 §e/l <密码> §f登录");
return true;
}
if (args.length < 2
|| (!args[0].equalsIgnoreCase("approve")
&& !args[0].equalsIgnoreCase("reject"))) {
wp.sendMessage("§c用法: /webbind approve|reject <id> [密码]");
return true;
}
final String bindReqId = args[1];
if (!bindReqId.matches("[0-9]+")) {
wp.sendMessage("§c无效的请求ID");
return true;
}
if (webManager == null) {
wp.sendMessage("§cWeb通信未启用,无法处理");
return true;
}
final boolean bindApprove = args[0].equalsIgnoreCase("approve");
// ★ 第56批:approve 时需要密码(Java端直接验证,不再依赖PHP复核)
if (bindApprove) {
// ★ 第57批续5: 改聊天框输入密码(不再要求命令行参数密码)
// ★ 第57批续10:接入统一风控,封禁期内直接拒绝
if (riskControl != null && riskControl.isBanned(wp.getName())) {
int rem = riskControl.getBanRemainingSeconds(wp.getName());
wp.sendMessage("§c§l[风控] §f密码错误次数过多,账号已被临时封禁 §e"
+ rem + " §f秒,请稍后再试");
return true;
}
pendingWebbindApprove.put(wp.getName(), bindReqId);
wp.sendMessage("§e请在聊天框输入密码完成验证");
wp.sendMessage("§7还是输入「拒绝」拒绝本次绑定");
return true;
}
// reject 不需要密码
wp.sendMessage("§e正在提交拒绝…");
webManager.submitWebEmailBindResult(bindReqId, wp.getName(),
"reject", "", status -> {
if (status == null) {
wp.sendMessage("§c提交失败,请稍后重试");
} else if ("rejected".equals(status)) {
wp.sendMessage("§e已拒绝,网页端将直接返回,不做任何处置");
} else if ("expired".equals(status)) {
wp.sendMessage("§c请求已超时,未做任何处置");
} else {
wp.sendMessage("§7请求当前状态: §f" + status + " §7(重复操作已忽略)");
}
});
return true;
}
// 绑定邮箱
if (cmdName.equals("绑定邮箱")) {
// ★ 3.0 安全修复:邮箱绑定必须登录后才能执行(未登录越权绑邮箱 + 任意收件人 SMTP 滥发)
@@ -9852,6 +9992,13 @@ public class Main extends JavaPlugin
return true;
}
String target = args[1];
// ★ 第57批续3:/del ban 拦截(ban 不作为待删除玩家账号的名字)
if (target.equalsIgnoreCase("ban")) {
sender.sendMessage("§cban 是封禁缺口确认命令,不是玩家账号名");
sender.sendMessage("§7确认拉回封禁:§a/sdf1_login confirm ban");
sender.sendMessage("§7确认解封:§a/sdf1_login cancel ban");
return true;
}
if (!db.userExists(target)) {
String twin = db.findSimilarUserNameIgnoreCase(target);
sender.sendMessage(
@@ -9910,6 +10057,23 @@ public class Main extends JavaPlugin
return true;
}
// ★ 第57批:confirm ban 确认封禁缺口拉回
if (sub.equals("confirm") && args.length >= 2 && args[1].equalsIgnoreCase("ban")) {
if (!isAdmin(sender)) { sender.sendMessage("\u00a7c权限不足"); return true; }
if (webManager == null) { sender.sendMessage("\u00a7cWeb通信未初始化"); return true; }
if (!webManager.hasBanPending()) { sender.sendMessage("§c当前没有待确认的封禁缺口"); return true; }
webManager.confirmBanGap(sender);
return true;
}
// ★ 第57批:cancel ban 确认解封(清空PHP封禁数据,不写回banned-players.json)
if (sub.equals("cancel") && args.length >= 2 && args[1].equalsIgnoreCase("ban")) {
if (!isAdmin(sender)) { sender.sendMessage("\u00a7c权限不足"); return true; }
if (webManager == null) { sender.sendMessage("\u00a7cWeb通信未初始化"); return true; }
if (!webManager.hasBanPending()) { sender.sendMessage("§c当前没有待确认的封禁缺口"); return true; }
webManager.cancelBanGap(sender);
return true;
}
if (sub.equals("confirm")) {
if (!isAdmin(sender)) {
sender.sendMessage("§c权限不足");
@@ -550,8 +550,8 @@ public class PVPArenaManager implements Listener {
w.setPVP(true);
// ★ 和平模式:排除敌对生物干扰PVP
w.setDifficulty(Difficulty.PEACEFUL);
w.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
w.setGameRule(GameRule.DO_WEATHER_CYCLE, false);
w.setGameRule(GameRules.ADVANCE_TIME, false);
w.setGameRule(GameRules.ADVANCE_WEATHER, false);
w.setTime(6000);
// ★ 不常驻出生点区块(与 WorldCreator.keepSpawnInMemory(false) 一致),避免同步加载区块卡主线程
w.setKeepSpawnInMemory(false);
+5 -5
View File
@@ -182,10 +182,10 @@ public class PVPTestManager implements Listener {
// 世界规则:常昼 / 无天气 / 关闭自然刷怪(只有我们生成的练习生物)/ PVP 开启
// ★ KEEP_INVENTORY=false:死亡掉落由事件处理器清空掉落物+复活时还原,避免gamerule与事件冲突
w.setPVP(true);
w.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
w.setGameRule(GameRule.DO_WEATHER_CYCLE, false);
w.setGameRule(GameRule.DO_MOB_SPAWNING, false);
w.setGameRule(GameRule.KEEP_INVENTORY, false);
w.setGameRule(GameRules.ADVANCE_TIME, false);
w.setGameRule(GameRules.ADVANCE_WEATHER, false);
w.setGameRule(GameRules.SPAWN_MONSTERS, false);
w.setGameRule(GameRules.KEEP_INVENTORY, false);
w.setTime(6000);
w.setKeepSpawnInMemory(false);
@@ -895,7 +895,7 @@ public class PVPTestManager implements Listener {
// 抵消皮革帽自带的1点护甲,使其不增加玩家对练习生物的伤害减免(纯用于挡太阳)
hm.addAttributeModifier(Attribute.ARMOR,
new AttributeModifier(
"pvp_test_no_armor", -1.0,
new NamespacedKey(plugin, "pvp_test_no_armor"), -1.0,
AttributeModifier.Operation.ADD_NUMBER));
hat.setItemMeta(hm);
}
@@ -88,23 +88,18 @@ public class RiskControlManager {
public int recordFailAttempt(Player player) {
String name = player.getName();
int maxAttempts = getMaxFailAttempts();
// 已被封禁,直接返回
if (isBanned(name)) {
return -1;
}
int current = failAttempts
.getOrDefault(name, 0) + 1;
failAttempts.put(name, current);
int remaining = Math.max(0, maxAttempts - current);
// 达到阈值 → 触发封禁
if (current >= maxAttempts) {
triggerBan(player);
}
return remaining;
}
@@ -117,16 +112,13 @@ public class RiskControlManager {
long durationMs = getBanDurationMs();
long until = System.currentTimeMillis() + durationMs;
banUntil.put(name, until);
String ip = plugin.getPlayerIP(player);
int banMinutes = (int) (durationMs / 60000);
plugin.getLogger().warning(
"[风控] 玩家 " + name
+ " 密码错误达到阈值("
+ getMaxFailAttempts() + "次)"
+ ",封禁 " + banMinutes + " 分钟");
// ★ 真实封禁:调用Bukkit BanList API阻止玩家重新加入服务器
String banReason = "§c§l[安全风控] 密码连续错误"
+ getMaxFailAttempts() + "次,"
@@ -145,7 +137,6 @@ public class RiskControlManager {
}
// 踢出玩家(无论BanList是否成功,立即踢出)
player.kickPlayer(banReason);
// 异步发送报警邮件
sendBanAlertEmail(name, ip, banMinutes);
}
@@ -164,7 +155,6 @@ public class RiskControlManager {
+ " 未绑定邮箱,跳过报警邮件");
return;
}
String subject =
"[安全警告] 您的账号已被临时封禁";
String body = "尊敬的玩家 " + playerName + ":\n\n"
@@ -187,7 +177,6 @@ public class RiskControlManager {
+ "如果非本人操作,建议您立即修改密码"
+ "(使用 /sdf1_login pw)。\n\n"
+ "--Sdf1_login 安全系统";
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.getEmail().sendBody(
+167 -1
View File
@@ -84,6 +84,13 @@ public class TwoFactorManager {
/** /2fa 命令分发,恒返回 true */
public boolean handleCommand(Player p, String[] args) {
// 第47批:先异步同步网页端绑定状态再分发(网页已绑定 → 游戏内立即显示/生效,
// add 也不会对着网页绑定重复申请);网络失败静默按本地状态走
syncWithWebThen(p, pl -> dispatchSub(pl, args));
return true;
}
private void dispatchSub(Player p, String[] args) {
String sub = args.length >= 1 ? args[0].toLowerCase() : "";
switch (sub) {
case "add":
@@ -113,7 +120,6 @@ public class TwoFactorManager {
sendHelp(p);
break;
}
return true;
}
public void sendHelp(Player p) {
@@ -271,6 +277,10 @@ public class TwoFactorManager {
plugin.getDb().setField(name, "twofa_enabled", 1);
plugin.getDb().setField(name, "twofa_pending_secret", "");
plugin.getDb().setField(name, "twofa_pending_at", 0L);
markBoundNow(name);
// 第47批:绑定结果同步到网页端(网页安全卡随之显示已绑定)
pushStateAsync(name, true, pending,
System.currentTimeMillis() / 1000L);
p.sendMessage("§a§l[2FA] §a二次验证绑定成功!");
p.sendMessage("§7 此后敏感操作需提供认证器动态码");
@@ -370,6 +380,9 @@ public class TwoFactorManager {
plugin.getDb().setField(name, "twofa_secret", "");
plugin.getDb().setField(name, "twofa_enabled", 0);
plugin.getDb().setField(name, "twofa_recovery_codes", "");
plugin.getDb().setField(name, "twofa_bound_at", 0L);
// 第47批:解绑结果同步到网页端
pushStateAsync(name, false, "", System.currentTimeMillis() / 1000L);
plugin.getLogger().info("[2FA] 玩家 " + name
+ " 使用恢复代码通过验证,已自动解绑二次验证");
sendUnbindAlarm(name, "恢复代码解绑", "玩家本人(恢复代码)",
@@ -653,6 +666,9 @@ public class TwoFactorManager {
plugin.getDb().setField(name, "twofa_enabled", 0);
// 解绑要彻底:恢复代码一并作废,避免残留可再次触发解绑/验证
plugin.getDb().setField(name, "twofa_recovery_codes", "");
plugin.getDb().setField(name, "twofa_bound_at", 0L);
// 第47批:解绑结果同步到网页端
pushStateAsync(name, false, "", System.currentTimeMillis() / 1000L);
Player pl = Bukkit.getPlayer(uuid);
String srcIp = "";
if (pl != null && pl.isOnline()) {
@@ -810,6 +826,9 @@ public class TwoFactorManager {
plugin.getDb().setField(target, "twofa_recovery_codes", "");
plugin.getDb().setField(target, "twofa_pending_secret", "");
plugin.getDb().setField(target, "twofa_pending_at", 0L);
plugin.getDb().setField(target, "twofa_bound_at", 0L);
// 第47批:解绑结果同步到网页端
pushStateAsync(target, false, "", System.currentTimeMillis() / 1000L);
out.sendMessage("§a[2FA] 强制解绑成功:玩家 " + target);
out.sendMessage("§7 已清空 twofa_secret / twofa_enabled / "
@@ -896,6 +915,153 @@ public class TwoFactorManager {
return true;
}
// ==================== 网页端绑定状态双向同步(第47批) ====================
/**
* 异步拉取网页端 2FA 状态并合并到本地,回主线程后执行后续动作。
* 网络失败/未启用:静默按本地状态直接继续,绝不阻塞命令。
*/
private void syncWithWebThen(Player p, java.util.function.Consumer<Player> next) {
if (plugin.webManager == null) {
next.accept(p);
return;
}
final UUID uuid = p.getUniqueId();
final String name = p.getName();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
WebManager.TwoFaWebState web = null;
try {
web = plugin.webManager.pullTwoFactorState(name);
} catch (Exception e) {
plugin.getLogger().warning("[2FA] 网页状态拉取异常: " + e.getMessage());
}
final WebManager.TwoFaWebState st = web;
Bukkit.getScheduler().runTask(plugin, () -> {
Player pl = Bukkit.getPlayer(uuid);
if (pl == null || !pl.isOnline()) return;
try {
applyWebState(pl, st);
} catch (Exception e) {
plugin.getLogger().warning("[2FA] 网页状态合并异常: " + e.getMessage());
}
next.accept(pl);
});
});
}
/**
* 合并网页端状态到本地(主线程)。
* 规则:网页绑定而本地未绑定 → 导入;两端都绑定但密钥不同 → bound_at 新者胜;
* 网页已解绑且比本地绑定更新 → 本地解绑;本地新 → 反向推送覆盖网页。
* 恢复代码不做跨端同步(网页 16 位明文 vs 游戏 6 位 SHA-256,格式不兼容,各自维护)。
*/
private void applyWebState(Player p, WebManager.TwoFaWebState web) {
if (web == null) return; // 拉取失败:保持本地状态
String name = p.getName();
boolean localBound = isEnabled(name);
String localSecret = str(plugin.getDb().getField(name, "twofa_secret"));
long localBoundAt = longOf(plugin.getDb().getField(name, "twofa_bound_at"));
if (web.bound && !web.secret.isEmpty()) {
if (!localBound) {
// 网页已绑定、本地未绑定 → 导入(修复截图场景)
plugin.getDb().setField(name, "twofa_secret", web.secret);
plugin.getDb().setField(name, "twofa_enabled", 1);
plugin.getDb().setField(name, "twofa_bound_at", web.boundAt);
plugin.getDb().setField(name, "twofa_pending_secret", "");
plugin.getDb().setField(name, "twofa_pending_at", 0L);
p.sendMessage("§a§l[2FA] §a已同步网页端的二次验证绑定");
p.sendMessage("§7 此后敏感操作需提供认证器动态码;网页端恢复代码在游戏内不可用");
plugin.getLogger().info("[2FA] 已导入网页端绑定: " + name
+ " (bound_at=" + web.boundAt + ")");
} else if (!localSecret.equals(web.secret)) {
if (web.boundAt > localBoundAt) {
// 网页端换绑更新 → 覆盖本地
plugin.getDb().setField(name, "twofa_secret", web.secret);
plugin.getDb().setField(name, "twofa_bound_at", web.boundAt);
p.sendMessage("§e§l[2FA] §e检测到网页端更新的绑定,已切换为新密钥");
plugin.getLogger().info("[2FA] 密钥冲突取网页端(更新): " + name);
} else {
// 本地更新 → 反向推送覆盖网页
plugin.getLogger().info("[2FA] 密钥冲突取本地(更新): " + name);
pushStateAsync(name, true, localSecret, localBoundAt);
}
}
return;
}
// 网页端未绑定/无记录
if (localBound) {
if (web.exists && web.updatedAt > localBoundAt) {
// 网页端主动解绑且更晚 → 本地跟随解绑
plugin.getDb().setField(name, "twofa_secret", "");
plugin.getDb().setField(name, "twofa_enabled", 0);
plugin.getDb().setField(name, "twofa_recovery_codes", "");
plugin.getDb().setField(name, "twofa_bound_at", 0L);
p.sendMessage("§e§l[2FA] §e网页端已解除二次验证,游戏内同步解绑");
plugin.getLogger().info("[2FA] 网页端解绑传播到本地: " + name);
} else if (!web.exists) {
// 网页端无记录(本地绑定未推送过)→ 推送本地绑定到网页
pushStateAsync(name, true, localSecret, localBoundAt);
}
}
// 两端都未绑定:无需动作(网页端 pending 待确认态不导入)
}
/** 异步推送本地绑定状态到网页端(解绑/绑定收口统一走这里) */
private void pushStateAsync(String name, boolean bound, String secret, long boundAt) {
if (plugin.webManager == null) return;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.webManager.pushTwoFactorState(name, bound, secret, boundAt);
} catch (Exception e) {
plugin.getLogger().warning("[2FA] 推送绑定状态异常: " + e.getMessage());
}
});
}
/**
* 玩家进服时静默同步(第47批,Main.PlayerJoinEvent 调用):
* 网页端绑定在进服即落本地,登录 IP 风控的 isEnabled() 读到的就是同步后状态。
* 无回调、无提示(除非真的导入了网页绑定);网络失败静默跳过。
*/
public void syncOnJoin(String playerName) {
if (plugin.webManager == null || playerName == null || playerName.isEmpty()) return;
final String name = playerName;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
WebManager.TwoFaWebState web = null;
try {
web = plugin.webManager.pullTwoFactorState(name);
} catch (Exception e) {
plugin.getLogger().warning("[2FA] 进服同步拉取异常: " + e.getMessage());
}
final WebManager.TwoFaWebState st = web;
Bukkit.getScheduler().runTask(plugin, () -> {
try {
Player pl = Bukkit.getPlayerExact(name);
if (pl == null || !pl.isOnline()) return; // 已下线:跳过(无处提示,/2fa 时还会再同步)
applyWebState(pl, st);
} catch (Exception e) {
plugin.getLogger().warning("[2FA] 进服同步合并异常: " + e.getMessage());
}
});
});
}
/** 本地绑定时刻(秒)—— 绑定完成时写入,供与网页端 bound_at 比新旧 */
private void markBoundNow(String name) {
plugin.getDb().setField(name, "twofa_bound_at", System.currentTimeMillis() / 1000L);
}
private static long longOf(Object v) {
if (v instanceof Number) return ((Number) v).longValue();
try {
return Long.parseLong(String.valueOf(v).trim());
} catch (Exception e) {
return 0L;
}
}
// ==================== 状态读取 ====================
private boolean isEnabled(Player p) {
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,5 +1,5 @@
name: Sdf1_login
version: 3.1
version: 3.11
main: Sdf1_login.Main
api-version: '1.21'
commands:
@@ -72,6 +72,9 @@ commands:
绑定邮箱:
description: 绑定邮箱
usage: /绑定邮箱
webbind:
description: 网页邮箱绑定批准/拒绝
usage: /webbind <approve|reject> <id>
chat:
description: 聊天过滤管理
usage: /chat