fix: 修正服务器反复传送刷屏日志、识别外站账号绑定流程、重连指数退避
部署到 Debian 服务器时暴露的三个真实缺陷: 1. 「被服务器传送」日志洪水 账号未完成服务器验证时服务端每 tick 强推一次位置,实测 3 分钟刷出 748 行日志, 几十分钟就会冲光 2000 条的日志环形缓冲并把 WebSocket 打满。 现改为首次立即记录、其余按 30 秒窗口聚合成一条,断开连接时刷出未汇总计数。 2. 外站账号绑定流程无法识别 原先只认 /login 与 /register,遇到简幻通这类要求 /auth <UID> <验证码> 或扫码绑定的 服务器时,表现只是「连上→被传送→120 秒后被踢→重连」的静默死循环。 现新增 NEED_AUTH_PATTERN 识别并输出可操作的中文告警(每连接一次), 新增 server.authCommand 模板供有固定验证码的服务器自动过验证。 3. 重连无退避 连续被踢时每 10 秒硬撞服务器。现按指数退避拉长(server.reconnectMaxDelayMs, 默认上限 120 秒),成功进入世界后清零;抽出 computeReconnectDelay 便于测试。 顺带补充 README 的「部署到服务器」章节(Node ≥ 22 要求、私有运行时、pm2、日志分文件、排障表)。 测试从 242 增至 252,全部通过。
这个提交包含在:
+98
-4
@@ -18,6 +18,22 @@ const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来|
|
||||
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
||||
/** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */
|
||||
const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i;
|
||||
/**
|
||||
* 服务器要求完成账号绑定 / 二次验证时的提示关键词。
|
||||
*
|
||||
* 典型场景:服务器用「简幻通」等外站账号体系,新账号必须先用 `/auth <UID> <验证码>`
|
||||
* 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。
|
||||
* 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。
|
||||
*/
|
||||
const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命令:\s*\/?auth|扫描.{0,6}二维码|扫码|完成验证|身份验证/i;
|
||||
|
||||
/**
|
||||
* 「被服务器传送」事件的日志聚合窗口(毫秒)。
|
||||
*
|
||||
* 账号未通过验证时,服务器会每 tick 强推一次位置,逐条记日志会在几分钟内
|
||||
* 刷掉整个日志环形缓冲并把 WebSocket 打满,因此这里按窗口聚合。
|
||||
*/
|
||||
const FORCED_MOVE_WINDOW_MS = 30000;
|
||||
|
||||
/**
|
||||
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
|
||||
@@ -91,6 +107,12 @@ class BotManager extends EventEmitter {
|
||||
this._commandTimer = null;
|
||||
/** 最近一次收到过的聊天消息(供 Web 控制台展示) */
|
||||
this.lastMessages = [];
|
||||
/** 本次连接内「被服务器传送」的计数与聚合定时器 */
|
||||
this._forcedMoveCount = 0;
|
||||
this._forcedMoveFirstAt = 0;
|
||||
this._forcedMoveTimer = null;
|
||||
/** 本次连接内是否已提示过「账号需要绑定 / 二次验证」 */
|
||||
this._needAuthWarned = false;
|
||||
}
|
||||
|
||||
// ==================== 连接生命周期 ====================
|
||||
@@ -131,6 +153,7 @@ class BotManager extends EventEmitter {
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
this._clearLoginConfirmTimer();
|
||||
this._clearForcedMoveTimer();
|
||||
this._commandQueue.length = 0;
|
||||
this._chatQueue.length = 0;
|
||||
if (this.bot) {
|
||||
@@ -158,8 +181,8 @@ class BotManager extends EventEmitter {
|
||||
if (this._manualDisconnect || !this.config.server.autoReconnect) return;
|
||||
if (this._reconnectTimer) return;
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.config.server.reconnectDelayMs;
|
||||
this._log(`${delay / 1000} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
|
||||
const delay = computeReconnectDelay(this.config.server, this.reconnectAttempts);
|
||||
this._log(`${Math.round(delay / 1000)} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
this.bot = null;
|
||||
@@ -173,6 +196,7 @@ class BotManager extends EventEmitter {
|
||||
_wireEvents(bot) {
|
||||
bot.on('login', () => {
|
||||
this.state = 'online';
|
||||
this._needAuthWarned = false;
|
||||
this._log('已连接到服务器,正在进入世界');
|
||||
this._handleLogin();
|
||||
});
|
||||
@@ -188,6 +212,7 @@ class BotManager extends EventEmitter {
|
||||
this.state = 'offline';
|
||||
this.loggedIn = false;
|
||||
this._clearLoginConfirmTimer();
|
||||
this._clearForcedMoveTimer();
|
||||
this._log('连接已断开:' + (reason || '未知原因'));
|
||||
this.emit('end', reason);
|
||||
this._scheduleReconnect();
|
||||
@@ -241,11 +266,38 @@ class BotManager extends EventEmitter {
|
||||
});
|
||||
|
||||
bot.on('forcedMove', () => {
|
||||
this._log('被服务器传送(位置强制同步)');
|
||||
this.emit('forcedMove');
|
||||
this._countForcedMove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合「被服务器传送」事件:首次立即记一条,其余按 {@link FORCED_MOVE_WINDOW_MS}
|
||||
* 窗口汇总成一条,避免账号未验证时把日志刷爆。
|
||||
*/
|
||||
_countForcedMove() {
|
||||
this._forcedMoveCount++;
|
||||
if (this._forcedMoveCount === 1) {
|
||||
this._forcedMoveFirstAt = Date.now();
|
||||
this._log('被服务器传送(位置强制同步)');
|
||||
}
|
||||
if (!this._forcedMoveTimer) {
|
||||
this._forcedMoveTimer = setTimeout(() => this._flushForcedMove(), FORCED_MOVE_WINDOW_MS);
|
||||
if (this._forcedMoveTimer.unref) this._forcedMoveTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/** 输出传送事件汇总并重置计数 */
|
||||
_flushForcedMove() {
|
||||
this._forcedMoveTimer = null;
|
||||
const extra = this._forcedMoveCount - 1;
|
||||
this._forcedMoveCount = 0;
|
||||
if (extra <= 0) return;
|
||||
const seconds = Math.max(1, Math.round((Date.now() - this._forcedMoveFirstAt) / 1000));
|
||||
this._log(`被服务器传送另有 ${extra} 次(最近 ${seconds} 秒内):通常是账号未完成服务器验证`
|
||||
+ `(如简幻通绑定)被反复拉回,或被 /spawn 类指令重置位置`, 'warn');
|
||||
}
|
||||
|
||||
/** 在世界里装配各子模块(Grim 层、寻路、进食、用船、耐久) */
|
||||
_installModules() {
|
||||
const bot = this.bot;
|
||||
@@ -318,6 +370,17 @@ class BotManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** 断开前把未汇总的传送计数刷出去并清掉定时器 */
|
||||
_clearForcedMoveTimer() {
|
||||
if (this._forcedMoveTimer) {
|
||||
clearTimeout(this._forcedMoveTimer);
|
||||
this._forcedMoveTimer = null;
|
||||
const extra = this._forcedMoveCount - 1;
|
||||
this._forcedMoveCount = 0;
|
||||
if (extra > 0) this._log(`被服务器传送另有 ${extra} 次(连接断开前)`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
/** 从聊天消息里识别服务器的登录 / 注册提示 */
|
||||
_handleAuthPrompt(message) {
|
||||
const server = this.config.server;
|
||||
@@ -329,6 +392,20 @@ class BotManager extends EventEmitter {
|
||||
this._log('登录 / 注册被服务器拒绝:' + message, 'error');
|
||||
return;
|
||||
}
|
||||
// 外站账号绑定 / 二次验证(如简幻通):密码无法自动完成,只能明确告知用户
|
||||
if (NEED_AUTH_PATTERN.test(message)) {
|
||||
if (!this._needAuthWarned) {
|
||||
this._needAuthWarned = true;
|
||||
this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error');
|
||||
this._log('该验证需要人工完成一次(游戏内扫码或 /auth <UID> <验证码>);'
|
||||
+ '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', 'warn');
|
||||
}
|
||||
if (server.authCommand) {
|
||||
this.sendCommand(fillTemplate(server.authCommand, password), { internal: true, immediate: true });
|
||||
this._armLoginConfirmFallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!password) return;
|
||||
if (LOGIN_OK_PATTERN.test(message)) {
|
||||
if (!this.loggedIn) this._log('登录成功', 'success');
|
||||
@@ -557,4 +634,21 @@ function fillTemplate(template, password) {
|
||||
return String(template).replace(/\{password\}/g, password);
|
||||
}
|
||||
|
||||
module.exports = { BotManager, fillTemplate, kindOfPosition };
|
||||
/**
|
||||
* 计算第 n 次重连的等待时长:按指数退避逐步拉长,并钳制在上限内。
|
||||
*
|
||||
* 连续重连失败(例如账号未完成服务器验证被反复踢出)时,固定 10 秒一次地硬撞服务器
|
||||
* 既无意义又可能触发风控;成功进入世界后 `reconnectAttempts` 会清零,间隔回到基准值。
|
||||
*
|
||||
* @param {{reconnectDelayMs: number, reconnectMaxDelayMs?: number}} serverConfig
|
||||
* @param {number} attempts 本次是第几次重连(从 1 开始)
|
||||
* @returns {number} 等待毫秒数
|
||||
*/
|
||||
function computeReconnectDelay(serverConfig, attempts) {
|
||||
const base = Math.max(1, Number(serverConfig.reconnectDelayMs) || 10000);
|
||||
const max = Math.max(base, Number(serverConfig.reconnectMaxDelayMs) || base);
|
||||
const step = Math.max(1, Math.trunc(attempts) || 1);
|
||||
return Math.min(max, base * Math.pow(2, Math.min(6, step - 1)));
|
||||
}
|
||||
|
||||
module.exports = { BotManager, fillTemplate, kindOfPosition, computeReconnectDelay, FORCED_MOVE_WINDOW_MS };
|
||||
|
||||
在新工单中引用
屏蔽一个用户