Compare commits

...

5 Commits

Author SHA1 Message Date
9924c8d1c9 Merge pull request 'fix(wle): 超时参数真正生效 —— 售后拦截 20s 不重试、表级探测走 querySlow' (#98) 2026-11-14 09:51:28 +08:00
936d391142 Merge pull request 'fix(wle): 正文写明物流快照是否覆盖核验时刻 / 是否过旧' (#97) 2026-11-14 09:51:25 +08:00
2f44cdce82 Merge pull request 'fix(wle): 钉钉推送失败后自动重试,不再「一天只有一次机会」' (#96) 2026-11-14 09:51:21 +08:00
9a7215a46c fix(wle): 正文写明物流快照是否覆盖核验时刻 / 是否过旧
11-11 09:10 那批:物流镜像表停在 11-10 11:53,算出 990 单**全部异常**,
钉钉正文却只有「已发货 990 单,异常 990 个包裹(100%)」。群里没法分辨
这是真异常还是数据没更新 —— 而这恰恰决定当天要不要去催快递。

PR #94 只修了「0 单」这一种(emptyWindow),有异常但快照降级的情形仍然是哑的。

- 新增 `buildFreshnessWarning`:`coversDeadline === false` 或 `stale === true`
  时,在数字行下面插一条 ⚠️ 告警,写明快照最新时刻、本轮核验时刻、未刷新时长,
  并明确「请以聚水潭实时物流为准,别按这个数去催快递」。
- 新增 `formatSnapshotMoment`:快照时刻**跨天必须带 MM-DD**。
  11-11 那批的 snapshotAt 是前一天的,只写「11:53」会被读成当天上午的新鲜数据
  (与 WST「历史单审核时间必须带 MM-DD」是同一个坑)。
- 只在「算出异常」时告警;0 单走原有 emptyWindow 分支,不叠加两条。
- 字段缺失(老缓存 / 接口未返回)静默跳过,不能把推送搞崩,也不能瞎报警。

测试:新增 5 条(未覆盖核验时刻含跨天日期 / 同日不写日期 / stale 带时长 /
正常批次无告警 / 字段缺失不崩),26/26 通过。
2026-11-11 11:14:31 +08:00
6c4f36f9fd fix(wle): 钉钉推送失败后自动重试,不再「一天只有一次机会」
11-11 09:10 那次推送撞上业务库连不上(状态里 `Failed to connect to
218.93.9.21:1433`)。老逻辑用 `chinaHourMinute(now) === config.sendTime`
严格相等判定,只给这一分钟一次机会:失败后当天彻底不推,群里一整天
没有任何消息,也没人知道任务其实跑过。

改成「推送时刻 + 重试窗口」模型:

- 新增纯函数 `resolveScheduledPushDecision`,每分钟 tick 只需问它一次;
  窗口 = sendTime ~ sendTime+30min,窗口内每 5 分钟重试一次,成功即停
  (`lastSentDate` 落库后后续 tick 走 already_sent_today)。
- 状态新增 `attemptDate` / `attemptCount` / `lastAttemptAt`:既做节流
  (避免每分钟重跑 240s 的全量核验),也做跨天归零。
- 窗口不跨日(sendTime+window 超过 23:59 截到当天最后一分钟),
  否则 pushDate 换天会把去重键带歪、同一天可能推两遍。
- 窗口关闭且当天始终未成功时打一条 error 日志,明确「今天不会再自己来了」。
- 窗口 / 间隔支持 `WLE_NOTIFY_RETRY_WINDOW_MINUTES`、
  `WLE_NOTIFY_RETRY_INTERVAL_MINUTES` 环境变量覆盖,现网调整不用发版。

测试:`test/warehouseLogisticsExceptionNotify.test.js` 新增 10 条边界用例
(到点首轮 / 未到点 / 已推过 / 间隔内节流 / 间隔到点 / 窗口边界含端点 /
窗口关闭 / 跨天归零 / 窗口不跨日 / 状态残缺不卡死 / sendTime 非法),
30/30 通过。
2026-11-11 11:12:26 +08:00
2 changed files with 379 additions and 5 deletions

View File

@ -6,6 +6,8 @@
* 业务口径(2026-10-22 定稿): * 业务口径(2026-10-22 定稿):
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群; * ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。 * **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
* ⚠️ 失败必须重试(2026-11-11 加):09:10 起 30 分钟内每 5 分钟再试一次,成功即停;
* 窗口过了不再补推(上午的数据下午才发会误导业务)。见 `resolveScheduledPushDecision`。
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。 * ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。 * 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。 * N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
@ -20,6 +22,9 @@
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。 * 关键词一般是「异常」,缺了会被拒(errcode 310000)。
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重, * ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。 * 服务重启不会重复轰炸;手动「立即推送」才强制重发。
* ⑤ 数据新鲜度必须进正文(2026-11-11 加):只要算出异常,就要说明物流快照
* 是否覆盖本轮核验时刻、是否过旧 —— 否则「990 单 100% 异常」到底是真异常
* 还是镜像表停在昨天,群里看不出来。见 `buildFreshnessWarning`。
* *
* 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。 * 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。
*/ */
@ -42,6 +47,32 @@ const DEFAULT_TICK_CRON = '* * * * *';
// 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败 // 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300; const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
// 🔥 2026-11-11 加:推送失败要重试,不能「一天只有一次机会」。
// 实况:11-11 09:10 那次撞上业务库连不上(状态里 `Failed to connect to 218.93.9.21:1433`),
// 老逻辑只在 `HH:mm === sendTime` 这一分钟内跑一次,失败后当天彻底不推 —— 群里一整天没消息。
// 现在改成:从推送时刻起 `RETRY_WINDOW_MINUTES` 分钟内,每隔 `RETRY_INTERVAL_MINUTES` 分钟再试,
// 成功即停(`lastSentDate` 落库后后续 tick 自动 skip),窗口过了就不再补(避免上午的数据下午才发)。
// 两个值都可用环境变量覆盖,现网想调不用改前端配置、也不用发版。
const DEFAULT_RETRY_WINDOW_MINUTES = 30;
const DEFAULT_RETRY_INTERVAL_MINUTES = 5;
function readPositiveMinutes(value, fallback, max) {
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) return fallback;
return Math.min(Math.round(num), max);
}
const RETRY_WINDOW_MINUTES = readPositiveMinutes(
process.env.WLE_NOTIFY_RETRY_WINDOW_MINUTES,
DEFAULT_RETRY_WINDOW_MINUTES,
12 * 60
);
const RETRY_INTERVAL_MINUTES = readPositiveMinutes(
process.env.WLE_NOTIFY_RETRY_INTERVAL_MINUTES,
DEFAULT_RETRY_INTERVAL_MINUTES,
RETRY_WINDOW_MINUTES
);
let tickRunning = false; let tickRunning = false;
function pad2(value) { function pad2(value) {
@ -72,6 +103,66 @@ function chinaHourMinute(date = new Date()) {
}).format(base); }).format(base);
} }
/** 'HH:mm' → 当天第几分钟(09:10 → 550)。非法输入返回 null,调用方按「不跑」处理 */
function minuteOfDay(hhmm) {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(hhmm || '').trim());
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
/**
* 定时器每一分钟问一次「现在该不该推」。纯函数,便于把时间窗口/节流的边界钉死在单测里。
*
* 返回 `{ run, reason, pushDate, attempt }`:
* · `already_sent_today` 今天已推成功 → 不再动(重试自然也停)
* · `before_time` 还没到配置的推送时刻
* · `window_closed` 已过「推送时刻 + 重试窗口」,不再补推(避免 10 点失败、下午才发出去)
* · `wait_interval` 上一轮失败距现在不到重试间隔,等着
* · `first_round` 到点第一次跑
* · `retry` 上一轮失败后的第 N 次重试
*
* ⚠️ 窗口**不跨日**:`sendTime + window` 超过 23:59 就截到当天最后一分钟。
* 跨日会让 `pushDate` 换天,去重键(`lastSentDate`)跟着变,容易出现同一天推两遍。
*/
function resolveScheduledPushDecision(options = {}) {
const now = options.now instanceof Date ? options.now : new Date(options.now ?? Date.now());
const pushDate = chinaDateKey(now);
const state = options.state && typeof options.state === 'object' ? options.state : {};
const windowMinutes = readPositiveMinutes(options.retryWindowMinutes, RETRY_WINDOW_MINUTES, 12 * 60);
const intervalMinutes = readPositiveMinutes(options.retryIntervalMinutes, RETRY_INTERVAL_MINUTES, windowMinutes);
if (state.lastSentDate === pushDate) {
return { run: false, reason: 'already_sent_today', pushDate, attempt: 0 };
}
const nowMinute = minuteOfDay(chinaHourMinute(now));
const startMinute = minuteOfDay(options.sendTime);
if (nowMinute === null || startMinute === null) {
return { run: false, reason: 'bad_send_time', pushDate, attempt: 0 };
}
if (nowMinute < startMinute) {
return { run: false, reason: 'before_time', pushDate, attempt: 0 };
}
const endMinute = Math.min(startMinute + windowMinutes, 23 * 60 + 59);
if (nowMinute > endMinute) {
return { run: false, reason: 'window_closed', pushDate, attempt: 0 };
}
// 当天的尝试次数:跨天或没有记录都归零,避免沿用昨天的计数把节流算歪
const attempt = state.attemptDate === pushDate ? Number(state.attemptCount) || 0 : 0;
if (attempt > 0) {
const lastAttemptMs = Date.parse(String(state.lastAttemptAt || ''));
if (Number.isFinite(lastAttemptMs)) {
const gapMinutes = (now.getTime() - lastAttemptMs) / 60000;
if (gapMinutes < intervalMinutes) {
return { run: false, reason: 'wait_interval', pushDate, attempt };
}
}
}
return { run: true, reason: attempt > 0 ? 'retry' : 'first_round', pushDate, attempt: attempt + 1 };
}
/** /**
* 字段清洗:换行/连续空白压成单空格。 * 字段清洗:换行/连续空白压成单空格。
* ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。 * ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。
@ -208,6 +299,51 @@ function buildTypeLine(data = {}) {
.join(' · '); .join(' · ');
} }
/**
* 快照时刻的展示形式:`2026-11-11 09:05:00` → `09:05`;**跨天则带 `MM-DD`**。
* ⚠️ 只显示 HH:mm 会把「昨天 11:53」读成「今天 11:53」。11-11 那批的 `snapshotAt`
* 其实是前一天的(镜像表停在 11-10 11:53),群里看到「快照最新只到 11:53」会误以为
* 当天上午的数据很新鲜 —— 与 WST「历史单审核时间必须带 MM-DD」是同一个坑。
*/
function formatSnapshotMoment(value, referenceDate = '') {
const text = String(value || '').trim();
if (text.length < 16) return text;
const datePart = text.slice(0, 10);
const timePart = text.slice(11, 16);
const refDate = String(referenceDate || '').trim().slice(0, 10);
if (refDate && datePart === refDate) return timePart;
return `${datePart.slice(5)} ${timePart}`;
}
/**
* 快照新鲜度告警 —— **有异常数据时才用**(0 单的情形由 `buildNotifyText` 里的 emptyWindow 分支处理)。
*
* 🔥 2026-11-11 实况:物流镜像表停在 11-10 11:53,09:10 那批算出 990 单**全部异常**,
* 正文却只有一句「异常 990 个(100%)」。群里没法分辨这是真异常还是数据没更新,
* 而这恰恰决定当天要不要去催快递。所以「没覆盖核验时刻」「快照过旧」必须写进正文,
* 不能只留在页面 hint 里 —— 群里干活的人看不到页面。
*/
function buildFreshnessWarning(freshness = {}, batch = {}) {
const info = freshness && typeof freshness === 'object' ? freshness : {};
const snapshotAt = formatSnapshotMoment(info.snapshotAt, batch.verifyDate);
if (info.coversDeadline === false) {
const deadline = formatSnapshotMoment(batch.deadline, batch.verifyDate);
return `⚠️ 数据未覆盖核验时刻:物流快照最新只到 ${snapshotAt || '未知'}`
+ `${deadline ? `,本轮核验 ${deadline}` : ''} —— 之后的揽收/中转轨迹没进库,异常量会偏大。`
+ '请以聚水潭实时物流为准,别按这个数去催快递。';
}
if (info.stale === true) {
const ageMinutes = Number(info.ageMinutes);
const hours = Number.isFinite(ageMinutes) && ageMinutes > 0
? `(已 ${Math.round((ageMinutes / 60) * 10) / 10} 小时未刷新)`
: '';
return `⚠️ 物流快照偏旧:最新只到 ${snapshotAt || '未知'}${hours} —— 状态可能滞后,`
+ '异常量仅供参考,请以聚水潭实时物流为准。';
}
return '';
}
function buildPageLink(pageUrl, verifyDate) { function buildPageLink(pageUrl, verifyDate) {
const base = String(pageUrl || DEFAULT_PAGE_URL); const base = String(pageUrl || DEFAULT_PAGE_URL);
if (!verifyDate) return base; if (!verifyDate) return base;
@ -245,10 +381,15 @@ function buildNotifyText(data = {}, options = {}) {
const typeLine = buildTypeLine(data); const typeLine = buildTypeLine(data);
if (typeLine) head.push(`类型:${typeLine}`); if (typeLine) head.push(`类型:${typeLine}`);
// 快照新鲜度告警:只对「算出了异常」的批次有意义 —— 11-11 那批 990 单全部异常,
// 正文却完全看不出数据停在昨天,业务只能凭印象判断要不要催快递。
const freshness = data.freshness || {};
const freshnessWarning = totalPackages > 0 ? buildFreshnessWarning(freshness, batch) : '';
if (freshnessWarning) head.push(freshnessWarning);
// 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」—— // 🔥 2026-11-02 修:0 单有两种完全不同的含义,不能都写「全部正常」——
// ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿; // ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿;
// ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。 // ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。
const freshness = data.freshness || {};
if (totalPackages === 0) { if (totalPackages === 0) {
if (freshness.emptyWindow && freshness.sourceStale) { if (freshness.emptyWindow && freshness.sourceStale) {
const hours = Number(freshness.sourceAgeMinutes) > 0 const hours = Number(freshness.sourceAgeMinutes) > 0
@ -479,8 +620,9 @@ async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', opt
} }
/** /**
* 调度:每分钟比对「北京时间 HH:mm == 配置的 sendTime」。 * 调度:每分钟问一次 `resolveScheduledPushDecision`。
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。 * 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
* 之所以不是「HH:mm 严格相等」:那样只给一次机会,09:10 撞上业务库连不上就整天不推(11-11 实况)。
*/ */
function startWarehouseLogisticsExceptionNotifyScheduler() { function startWarehouseLogisticsExceptionNotifyScheduler() {
const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true'; const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true';
@ -489,6 +631,7 @@ function startWarehouseLogisticsExceptionNotifyScheduler() {
return; return;
} }
const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim(); const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
let windowClosedLoggedDate = '';
cron.schedule(expression, () => { cron.schedule(expression, () => {
if (tickRunning) return; if (tickRunning) return;
tickRunning = true; tickRunning = true;
@ -497,8 +640,32 @@ function startWarehouseLogisticsExceptionNotifyScheduler() {
const now = new Date(); const now = new Date();
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig(); const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
if (!config.enabled) return; if (!config.enabled) return;
if (chinaHourMinute(now) !== config.sendTime) return; const state = await loadWarehouseLogisticsExceptionNotifyState();
await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now }); const decision = resolveScheduledPushDecision({ now, sendTime: config.sendTime, state });
if (!decision.run) {
// 窗口刚过、当天又一直没推成功 → 打一条,明确「今天不会自己来了」,别让人一直等
if (decision.reason === 'window_closed' && windowClosedLoggedDate !== decision.pushDate) {
windowClosedLoggedDate = decision.pushDate;
console.error(
`[WleNotify] 今日推送窗口已关闭(${config.sendTime} 起 ${RETRY_WINDOW_MINUTES} 分钟内重试,`
+ '始终未成功),今天不再自动重试,请人工处理'
);
}
return;
}
// 先落「本轮尝试」:失败后下一分钟的 tick 才能按间隔节流。
// 成功时 push 内部会覆盖 lastSentDate(去重键),此后本函数直接走 already_sent_today。
await saveWarehouseLogisticsExceptionNotifyState({
...state,
attemptDate: decision.pushDate,
attemptCount: decision.attempt,
lastAttemptAt: now.toISOString(),
});
await runWarehouseLogisticsExceptionNotifyJob(
decision.reason === 'retry' ? 'daily_retry' : 'daily_schedule',
{ now }
);
} catch (error) { } catch (error) {
console.error('[WleNotify] 定时检查失败:', error.message || error); console.error('[WleNotify] 定时检查失败:', error.message || error);
} finally { } finally {
@ -507,23 +674,31 @@ function startWarehouseLogisticsExceptionNotifyScheduler() {
})(); })();
}, { timezone: TIMEZONE }); }, { timezone: TIMEZONE });
console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`); console.log(
`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送,`
+ `失败后每 ${RETRY_INTERVAL_MINUTES} 分钟重试、最多 ${RETRY_WINDOW_MINUTES} 分钟内 (${TIMEZONE})`
);
} }
module.exports = { module.exports = {
DEFAULT_RETRY_INTERVAL_MINUTES,
DEFAULT_RETRY_WINDOW_MINUTES,
DEFAULT_TICK_CRON, DEFAULT_TICK_CRON,
SAFE_CONTENT_BYTES, SAFE_CONTENT_BYTES,
TIMEZONE, TIMEZONE,
buildCenterLines, buildCenterLines,
buildDetailLine, buildDetailLine,
buildFreshnessWarning,
buildNotifyText, buildNotifyText,
buildTypeLine, buildTypeLine,
chinaDateKey, chinaDateKey,
chinaHourMinute, chinaHourMinute,
groupRowsByWarehouse, groupRowsByWarehouse,
minuteOfDay,
pickDetailRows, pickDetailRows,
cleanCell, cleanCell,
pushWarehouseLogisticsExceptionNotify, pushWarehouseLogisticsExceptionNotify,
resolveScheduledPushDecision,
runWarehouseLogisticsExceptionNotifyJob, runWarehouseLogisticsExceptionNotifyJob,
startWarehouseLogisticsExceptionNotifyScheduler, startWarehouseLogisticsExceptionNotifyScheduler,
}; };

View File

@ -541,4 +541,203 @@ test('数据源正常但当天 0 条记录 → 说明是「库里没有记录」
assert.match(built.text, /请到聚水潭核对/); assert.match(built.text, /请到聚水潭核对/);
assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更'); assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更');
assert.ok(!built.text.includes('全部正常'), '0 条不能糊成「全部正常」'); assert.ok(!built.text.includes('全部正常'), '0 条不能糊成「全部正常」');
assert.ok(!built.text.includes('数据未覆盖核验时刻'), '0 单已有专门告警,别再叠一条快照告警');
});
// ── ⑥ 有异常时的快照新鲜度也必须进正文(2026-11-11)──────────────────
// 实况:镜像表停在 11-10 11:53,09:10 那批算出 990 单全部异常,正文只有一句
// 「异常 990 个(100%)」。群里没法分辨「真异常」和「数据停在昨天」,
// 而这恰恰决定当天要不要催快递。下面把两种降级(未覆盖核验时刻 / 快照过旧)钉死。
test('快照没覆盖核验时刻 → 正文点名「未覆盖」,并把前一天的日期带上(不能只写 11:53)', () => {
const data = sampleData({
freshness: {
snapshotAt: '2026-10-21 11:53:00',
ageMinutes: 1270,
stale: true,
coversDeadline: false,
emptyWindow: false,
},
});
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
assert.match(built.text, /数据未覆盖核验时刻/);
assert.match(built.text, /最新只到 10-21 11:53/, '跨天必须带 MM-DD,否则会被读成当天 11:53');
assert.match(built.text, /本轮核验 09:00/, '同一天的时刻不带日期');
assert.match(built.text, /别按这个数去催快递/);
// 抬头仍要带「异常」关键词,否则钉钉自定义关键词会拒收
assert.match(built.text, /【仓储物流转运异常】/);
});
test('快照同日但没过核验时刻 → 只写时分,不啰嗦日期', () => {
const data = sampleData({
freshness: { snapshotAt: '2026-10-22 08:30:00', ageMinutes: 30, stale: false, coversDeadline: false },
});
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
assert.match(built.text, /最新只到 08:30/);
assert.ok(!built.text.includes('10-22 08:30'), '同一天不重复写日期');
});
test('快照过旧(stale)→ 正文写「偏旧」+ 未刷新时长,异常量标注仅供参考', () => {
const data = sampleData({
freshness: { snapshotAt: '2026-10-22 09:05:00', ageMinutes: 200, stale: true, coversDeadline: true },
});
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
assert.match(built.text, /物流快照偏旧/);
assert.match(built.text, /09:05/);
assert.match(built.text, /已 3\.3 小时未刷新/);
assert.match(built.text, /仅供参考/);
});
test('快照正常 → 正文不出现任何新鲜度告警(别把正常批次也刷成黄色)', () => {
const data = sampleData({
freshness: { snapshotAt: '2026-10-22 09:05:00', ageMinutes: 3, stale: false, coversDeadline: true },
});
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
assert.ok(!built.text.includes('数据未覆盖核验时刻'));
assert.ok(!built.text.includes('物流快照偏旧'));
assert.match(built.text, /异常 144 个包裹/);
});
test('快照字段缺失(老缓存 / 接口未返回)→ 不能崩,也不能瞎报警', () => {
const data = sampleData({ freshness: undefined });
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
assert.match(built.text, /异常 144 个包裹/);
assert.ok(!built.text.includes('⚠️ 数据未覆盖'));
assert.equal(job.buildFreshnessWarning(undefined, {}), '');
assert.equal(job.buildFreshnessWarning({ coversDeadline: null }, {}), '');
});
// ── ⑥ 推送失败要重试,不能「一天只有一次机会」(2026-11-11)──────────────
// 实况:11-11 09:10 那次撞上业务库连不上(状态里 `Failed to connect to 218.93.9.21:1433`),
// 老逻辑用 `HH:mm === sendTime` 严格相等,只给一分钟机会,失败后当天彻底不推、群里一整天没消息。
// 下面把窗口与节流的边界钉死,避免改成「每分钟都重跑」把 240s 的全量核验打成 DDoS。
function beijing(isoText) {
return new Date(isoText); // 测试用 UTC 写法,避免本机时区把断言带偏
}
test('重试调度:到点第一次跑,推成功之后当天不再跑', () => {
const sendTime = '09:10';
const first = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T01:10:00Z'), // 北京 09:10
sendTime,
state: {},
});
assert.equal(first.run, true);
assert.equal(first.reason, 'first_round');
assert.equal(first.attempt, 1);
assert.equal(first.pushDate, '2026-11-11');
const sent = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T01:11:00Z'), // 北京 09:11,09:10 那轮已成功
sendTime,
state: { lastSentDate: '2026-11-11', lastSentAt: '2026-11-11T01:10:03Z' },
});
assert.equal(sent.run, false);
assert.equal(sent.reason, 'already_sent_today');
});
test('重试调度:没到点的分钟一律不跑(不会被「窗口」提前触发)', () => {
const decision = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T01:09:00Z'), // 北京 09:09
sendTime: '09:10',
state: {},
});
assert.equal(decision.run, false);
assert.equal(decision.reason, 'before_time');
});
test('重试调度:失败后按 5 分钟间隔重试,间隔内不重复拉全量核验', () => {
const sendTime = '09:10';
const failedState = { lastAttemptAt: '2026-11-11T01:10:00Z', attemptDate: '2026-11-11', attemptCount: 1 };
const tooSoon = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T01:13:00Z'), // 距上次尝试 3 分钟
sendTime,
state: failedState,
});
assert.equal(tooSoon.run, false);
assert.equal(tooSoon.reason, 'wait_interval');
const retry = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T01:15:00Z'), // 距上次尝试 5 分钟,正好到间隔
sendTime,
state: failedState,
});
assert.equal(retry.run, true);
assert.equal(retry.reason, 'retry');
assert.equal(retry.attempt, 2);
});
test('重试调度:过了「推送时刻 + 30 分钟」窗口就不再补推', () => {
const sendTime = '09:10';
const failedState = { lastAttemptAt: '2026-11-11T01:35:00Z', attemptDate: '2026-11-11', attemptCount: 6 };
const inside = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T01:40:00Z'), // 北京 09:40,窗口最后一分钟
sendTime,
state: failedState,
});
assert.equal(inside.run, true, '窗口边界(含)仍要重试');
const closed = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T01:41:00Z'), // 北京 09:41
sendTime,
state: failedState,
});
assert.equal(closed.run, false);
assert.equal(closed.reason, 'window_closed');
});
test('重试调度:跨天后尝试次数归零,昨天的失败不节流今天的首轮', () => {
const decision = job.resolveScheduledPushDecision({
now: beijing('2026-11-12T01:10:00Z'), // 北京 11-12 09:10
sendTime: '09:10',
state: { lastAttemptAt: '2026-11-11T01:35:00Z', attemptDate: '2026-11-11', attemptCount: 6 },
});
assert.equal(decision.run, true);
assert.equal(decision.reason, 'first_round');
assert.equal(decision.attempt, 1);
});
test('重试调度:重试窗口不跨日(23:50 推送不会被次日 00:10 当成「窗口内」)', () => {
const decision = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T16:10:00Z'), // 北京 11-12 00:10
sendTime: '23:50',
state: {},
});
assert.equal(decision.run, false);
assert.equal(decision.reason, 'before_time');
});
test('重试调度:尝试记录残缺(有次数没时间)时不能卡死,该重试就重试', () => {
const decision = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T01:11:00Z'),
sendTime: '09:10',
state: { attemptDate: '2026-11-11', attemptCount: 2, lastAttemptAt: '' },
});
assert.equal(decision.run, true);
assert.equal(decision.reason, 'retry');
assert.equal(decision.attempt, 3);
});
test('重试调度:sendTime 非法时安静不跑(配置坏了不能把定时任务打崩)', () => {
const decision = job.resolveScheduledPushDecision({
now: beijing('2026-11-11T01:10:00Z'),
sendTime: 'not-a-time',
state: {},
});
assert.equal(decision.run, false);
assert.equal(decision.reason, 'bad_send_time');
});
test('重试参数:默认 30 分钟窗口 / 5 分钟间隔,环境变量可覆盖', () => {
assert.equal(job.DEFAULT_RETRY_WINDOW_MINUTES, 30);
assert.equal(job.DEFAULT_RETRY_INTERVAL_MINUTES, 5);
assert.equal(job.minuteOfDay('09:10'), 550);
assert.equal(job.minuteOfDay('00:00'), 0);
assert.equal(job.minuteOfDay('23:59'), 1439);
assert.equal(job.minuteOfDay('9:10'), null, '必须是零填充的 HH:mm');
assert.equal(job.minuteOfDay(''), null);
}); });