feat: 1.7.0 落地 TODO 全部待办(地面修正 / 手动执行器 / 自救 / 回城 / 租约 / 通知)

按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。

- 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、
  出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行)
- 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标
- 新增目标区块租约表(targetLease)避免多机器人撞同一块
- 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier)
- 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索

顺带修掉两个影响部署的细节:
- 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 ——
  在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全;
  显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。
- package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0,
  与 CHANGELOG 的语义化版本声明脱节)。

测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。
这个提交包含在:
lyizai
2026-09-28 22:46:39 +08:00
父节点 8d1b8d5122
当前提交 51664c55b6
共修改 31 个文件,包含 3535 行新增和 251 行删除
+140
查看文件
@@ -0,0 +1,140 @@
'use strict';
/**
* 通知提醒(Webhook):需要人工介入时把消息推到飞书机器人等渠道。
*
* 服务器只给 120 秒输入登录验证码,错过就要等下一次重登 —— 这是整个机器人流程里
* 几乎唯一必须人工介入的环节(另一个是「原点自救失败」说明可能被切到了登录服),
* 因此值得在手机上也收到提醒,而不是只写在日志里等人翻。
*
* 实现刻意做到最小:POST 一个 JSON,默认按飞书 / Lark 自定义机器人的文本消息格式
* (`{"msgtype":"text","content":{"text":"…"}}`)—— 在群聊里加一个自定义机器人
* 就能拿到 Webhook 地址,零依赖。
*/
/** 同一 key 的最小提醒间隔(毫秒):重连循环里同一个问题只提醒一次 */
const DEFAULT_MIN_INTERVAL_MS = 60000;
/** 单次请求超时(毫秒) */
const REQUEST_TIMEOUT_MS = 10000;
class Notifier {
/**
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
* @param {{fetchImpl?: Function, minIntervalMs?: number}} [options]
*/
constructor(config, logger, options) {
const opts = options || {};
this.config = config;
this.logger = logger || null;
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
this.minIntervalMs = Number.isFinite(opts.minIntervalMs) ? opts.minIntervalMs : DEFAULT_MIN_INTERVAL_MS;
/** @type {Map<string, number>} 去重 key → 上次发送时间 */
this._lastSentAt = new Map();
this.sent = 0;
this.failed = 0;
this.lastError = '';
this.lastSentAt = 0;
}
/** @returns {string} Webhook 地址(配置优先,其次环境变量 NOTIFY_WEBHOOK_URL) */
get webhookUrl() {
const cfg = (this.config && this.config.notify) || {};
const configured = typeof cfg.webhookUrl === 'string' ? cfg.webhookUrl.trim() : '';
if (configured) return configured;
return String(process.env.NOTIFY_WEBHOOK_URL || '').trim();
}
/** @returns {boolean} 是否已开启且配了地址 */
isEnabled() {
const cfg = (this.config && this.config.notify) || {};
return cfg.enabled === true && this.webhookUrl !== '';
}
/**
* 发送一条提醒。
*
* @param {string} text 正文
* @param {{key?: string, force?: boolean}} [options]
* key 用于去重:同一个 key 在 `minIntervalMs` 内只会发一次(force 可跳过去重)
* @returns {Promise<boolean>} 是否成功发出
*/
async notify(text, options) {
const opts = options || {};
if (!this.isEnabled()) return false;
const key = opts.key || 'default';
const now = Date.now();
const last = this._lastSentAt.get(key);
if (opts.force !== true && Number.isFinite(last) && now - last < this.minIntervalMs) {
return false; // 刚提醒过同一个问题,不重复打扰
}
this._lastSentAt.set(key, now);
const payload = {
msgtype: 'text',
content: { text: String(text) }
};
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) : null;
try {
const response = await this.fetchImpl(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller ? controller.signal : undefined
});
if (!response || response.ok !== true) {
this.failed++;
this.lastError = `HTTP ${response ? response.status : '???'}`;
this._log('通知发送失败:' + this.lastError, 'warn');
return false;
}
this.sent++;
this.lastSentAt = Date.now();
this.lastError = '';
this._log('已发送通知提醒:' + firstLine(text));
return true;
} catch (err) {
this.failed++;
this.lastError = err && err.message ? err.message : String(err);
this._log('通知发送异常:' + this.lastError, 'warn');
return false;
} finally {
if (timer) clearTimeout(timer);
}
}
/** @returns {object} 供控制台展示的状态(不暴露完整 Webhook 地址) */
snapshot() {
const url = this.webhookUrl;
return {
enabled: this.isEnabled(),
configured: url !== '',
/** 只显示域名部分,避免把带凭证的完整 Webhook 打进接口响应 */
host: url ? safeHost(url) : '',
sent: this.sent,
failed: this.failed,
lastError: this.lastError,
lastSentAt: this.lastSentAt
};
}
_log(message, level) {
if (this.logger) this.logger.log(message, level);
}
}
/** 取 URL 的域名(拿不到就返回空串) */
function safeHost(url) {
try {
return new URL(url).host;
} catch {
return '';
}
}
/** 取多行文本的第一行(日志里只记一行摘要) */
function firstLine(text) {
return String(text).split('\n')[0].slice(0, 120);
}
module.exports = { Notifier, DEFAULT_MIN_INTERVAL_MS };