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,全部通过。
这个提交包含在:
lyizai
2026-09-27 22:02:24 +08:00
父节点 39624da2d5
当前提交 d6b1339867
共修改 6 个文件,包含 297 行新增和 10 行删除
+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 };
+18 -1
查看文件
@@ -199,6 +199,14 @@ function defaultConfig() {
loginCommand: 'login {password}',
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */
registerCommand: '',
/**
* 账号绑定 / 二次验证指令模板(`{password}` 会被替换),留空则不发送。
*
* 用于「简幻通」这类外站账号体系:新账号必须先用 `/auth <UID> <验证码>` 或扫码绑定。
* 扫码类验证无法自动完成,此时保持留空即可——程序会在日志里明确提示需要人工验证一次;
* 若该服务器提供固定验证码,把 `auth {password}` 之类填进来就能自动过。
*/
authCommand: '',
/**
* 登录结果确认超时(毫秒)。
* 部分离线服登录成功后不回明确提示,超时后按「已登录」放行,
@@ -210,7 +218,14 @@ function defaultConfig() {
/** 断线后是否自动重连 */
autoReconnect: true,
/** 重连间隔(毫秒) */
reconnectDelayMs: 10000
reconnectDelayMs: 10000,
/**
* 重连间隔上限(毫秒)。
*
* 连续重连失败(例如账号未完成服务器验证被反复踢出)时按指数退避拉长间隔,
* 避免每 10 秒一次地硬撞服务器;成功进入世界后计数清零、间隔回到 reconnectDelayMs。
*/
reconnectMaxDelayMs: 120000
}
};
}
@@ -311,7 +326,9 @@ function validateConfig(config) {
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
config.server.reconnectMaxDelayMs = clampInt(config.server.reconnectMaxDelayMs, config.server.reconnectDelayMs, 3600000, 120000);
config.server.loginConfirmTimeoutMs = clampInt(config.server.loginConfirmTimeoutMs, 0, 300000, 10000);
if (typeof config.server.authCommand !== 'string') config.server.authCommand = '';
if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
config.waterMode = WATER_MODE.AVOID;
+3 -1
查看文件
@@ -270,10 +270,12 @@ const CONFIG_SCHEMA = {
{ path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' },
{ path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_BOTS / MC_PASSWORD' },
{ path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' },
{ path: 'server.authCommand', label: '账号绑定 / 二次验证模板', type: 'text', hint: '如简幻通的 auth {password};扫码类验证无法自动完成,留空即可(日志会提示人工验证一次)' },
{ path: 'server.loginConfirmTimeoutMs', label: '登录结果确认超时(毫秒)', type: 'number', min: 0, hint: '超时未收到登录提示则按已登录放行;0 表示禁用兜底' },
{ path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 },
{ path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' },
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 }
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 },
{ path: 'server.reconnectMaxDelayMs', label: '重连间隔上限(毫秒)', type: 'number', min: 1000, hint: '连续重连失败时按指数退避,最多退到这个间隔' }
]
}
]