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,全部通过。
这个提交包含在:
+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);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户