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 行删除
+107 -4
查看文件
@@ -6,7 +6,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { BotManager, kindOfPosition } = require('../src/bot/botManager');
const { BotManager, kindOfPosition, computeReconnectDelay } = require('../src/bot/botManager');
const { ChatLog, ChatKind } = require('../src/core/chatLog');
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
@@ -20,6 +20,7 @@ function setup(options) {
if (opts.config && opts.config.server) {
Object.assign(config.server, opts.config.server);
}
const logger = new Logger({ echo: false });
const manager = new BotManager({
id: 'BotA',
username: 'BotA',
@@ -28,12 +29,11 @@ function setup(options) {
port: 25565,
version: '1.21.4',
label: '主号'
}, config, new Logger({ echo: false }), { chatLog });
}, config, logger, { chatLog });
const sent = [];
manager.bot = { chat: (text) => sent.push(text) };
return { manager, chatLog, config, sent };
return { manager, chatLog, config, sent, logger };
}
/** 等一轮宏任务,让指令节流定时器真正把内容写出 */
function drainQueue() {
return new Promise((resolve) => setTimeout(resolve, 5));
@@ -191,3 +191,106 @@ test('snapshot 暴露多机器人需要的 id / 备注 / 登录与操作许可',
assert.strictEqual(snap.canOperate, false);
assert.strictEqual(snap.loginFailed, false);
});
// ==================== 被服务器反复传送时的日志聚合 ====================
test('被服务器传送:首次立即记录,其余聚合为一条汇总', () => {
const { manager, logger } = setup();
manager._countForcedMove();
const afterFirst = logger.list().filter((e) => e.message.includes('被服务器传送'));
assert.strictEqual(afterFirst.length, 1, '首次传送应立即记一条');
// 模拟账号未验证时被每 tick 强推位置:再触发 200 次不应继续刷日志
for (let i = 0; i < 200; i++) manager._countForcedMove();
assert.strictEqual(logger.list().filter((e) => e.message.includes('被服务器传送')).length, 1,
'同一窗口内的重复传送不得逐条记录');
manager._flushForcedMove();
const lines = logger.list().filter((e) => e.message.includes('被服务器传送'));
assert.strictEqual(lines.length, 2, '窗口结束时汇总成一条');
assert.ok(lines[1].message.includes('另有 200 次'), '汇总应包含次数');
assert.strictEqual(lines[1].level, 'warn');
});
test('被服务器传送:窗口内只有一次时不输出多余汇总', () => {
const { manager, logger } = setup();
manager._countForcedMove();
manager._flushForcedMove();
assert.strictEqual(logger.list().filter((e) => e.message.includes('被服务器传送')).length, 1);
});
test('被服务器传送:断开连接时把未汇总的计数刷出去并清掉定时器', () => {
const { manager, logger } = setup();
manager._countForcedMove();
for (let i = 0; i < 9; i++) manager._countForcedMove();
manager._clearForcedMoveTimer();
assert.strictEqual(manager._forcedMoveTimer, null);
assert.strictEqual(manager._forcedMoveCount, 0);
const lines = logger.list().filter((e) => e.message.includes('被服务器传送'));
assert.ok(lines.some((e) => e.message.includes('另有 9 次')));
});
// ==================== 账号绑定 / 二次验证 ====================
test('服务器要求账号绑定时给出可操作的中文告警(且只提示一次)', () => {
const { manager, logger } = setup();
manager.config.server.loginCommand = 'login {password}';
manager.loggedIn = false;
manager._handleAuthPrompt('为了保障服务器安全,您需要绑定您的简幻通账号。');
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
manager._handleAuthPrompt('请在 120 秒内完成验证,否则将被踢出服务器。');
const errors = logger.list().filter((e) => e.level === 'error' && e.message.includes('账号绑定'));
assert.strictEqual(errors.length, 1, '同一连接内只提示一次,避免刷屏');
assert.ok(logger.list().some((e) => e.message.includes('需要人工完成一次')));
assert.strictEqual(manager.loggedIn, false, '未完成验证时不得放行操作');
});
test('配置了账号绑定指令模板时自动发送(绕过门禁)', () => {
const { manager, sent } = setup();
manager.config.server.authCommand = 'auth {password}';
manager.loggedIn = false;
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
assert.deepStrictEqual(sent, ['/auth pw']);
});
test('未配置账号绑定模板时不乱发指令', () => {
const { manager, sent } = setup();
manager.config.server.authCommand = '';
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
assert.deepStrictEqual(sent, []);
});
test('重新登录后绑定告警标记被复位', () => {
const { manager } = setup();
manager._handleAuthPrompt('您需要绑定您的简幻通账号');
assert.strictEqual(manager._needAuthWarned, true);
// 模拟 login 事件的处理
manager._needAuthWarned = false;
assert.strictEqual(manager._needAuthWarned, false);
});
// ==================== 重连退避 ====================
test('computeReconnectDelay 按指数退避并在上限处钳制', () => {
const server = { reconnectDelayMs: 10000, reconnectMaxDelayMs: 120000 };
assert.strictEqual(computeReconnectDelay(server, 1), 10000);
assert.strictEqual(computeReconnectDelay(server, 2), 20000);
assert.strictEqual(computeReconnectDelay(server, 3), 40000);
assert.strictEqual(computeReconnectDelay(server, 4), 80000);
assert.strictEqual(computeReconnectDelay(server, 5), 120000, '超过上限后钳制');
assert.strictEqual(computeReconnectDelay(server, 30), 120000);
});
test('computeReconnectDelay 缺省上限时退化为固定间隔', () => {
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000 }, 1), 5000);
// 未配置上限时 max 取基准值,等价于固定间隔(保持旧行为,不突然拉长重连)
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000 }, 4), 5000);
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000, reconnectMaxDelayMs: 5000 }, 4), 5000);
});
test('config 校验补全 authCommand 与 reconnectMaxDelayMs', () => {
const { config } = setup();
assert.strictEqual(config.server.authCommand, '');
assert.strictEqual(config.server.reconnectMaxDelayMs, 120000);
});