Compare commits
No commits in common. "main" and "yanhui-wle-notify-freshness-1111" have entirely different histories.
main
...
yanhui-wle
@ -76,24 +76,6 @@ function isTransientError(error) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ `options.timeoutMs` 是个「看着能用、其实没人读」的参数 —— 历史踩坑,留个告警别再犯。
|
||||
*
|
||||
* 根因:mssql 12.x 上请求级 `request.timeout` 不生效,超时只能靠**池配置级** requestTimeout,
|
||||
* 而共享池(query())的池级超时在 pool 创建时就定死了,调用方改不了。想放宽只有两条路:
|
||||
* · querySlow(sql, params, { connectRequestTimeoutMs }) —— 临时独立连接,可任意放宽;
|
||||
* · query(sql, params, { requestTimeout }) —— 只对独立连接路径(queryWithFreshConnection)有意义。
|
||||
* 2026-11-14 实况:WLE 服务层两处 `{ timeoutMs: 240000 }` 想着「给足 4 分钟」,
|
||||
* 实际一路按 60s/300s 跑,11-13 那轮钉钉推送就因此超时没推出去。
|
||||
*/
|
||||
function warnLegacyTimeoutOption(options, caller) {
|
||||
if (!options || options.timeoutMs === undefined) return;
|
||||
console.warn(
|
||||
`[CloudDB] ${caller} 收到无效参数 timeoutMs=${options.timeoutMs}(已被忽略,不会放宽超时);`
|
||||
+ '需要更长超时请改用 querySlow + connectRequestTimeoutMs'
|
||||
);
|
||||
}
|
||||
|
||||
function isTruthy(value) {
|
||||
return value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1';
|
||||
}
|
||||
@ -1054,7 +1036,6 @@ const resetPool = async () => {
|
||||
* @param {object} params - 命名参数对象
|
||||
*/
|
||||
const query = async (sqlText, params = {}, options = {}) => {
|
||||
warnLegacyTimeoutOption(options, 'query()');
|
||||
const maxAttempts = parsePositiveInt(options.maxAttempts, 2);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
@ -1091,7 +1072,6 @@ const query = async (sqlText, params = {}, options = {}) => {
|
||||
};
|
||||
|
||||
const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
|
||||
warnLegacyTimeoutOption(options, 'queryWithFreshConnection()');
|
||||
const maxAttempts = parsePositiveInt(options.maxAttempts, 2);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
|
||||
@ -6,8 +6,6 @@
|
||||
* 业务口径(2026-10-22 定稿):
|
||||
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
|
||||
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
|
||||
* ⚠️ 失败必须重试(2026-11-11 加):09:10 起 30 分钟内每 5 分钟再试一次,成功即停;
|
||||
* 窗口过了不再补推(上午的数据下午才发会误导业务)。见 `resolveScheduledPushDecision`。
|
||||
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
|
||||
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
|
||||
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
|
||||
@ -47,32 +45,6 @@ const DEFAULT_TICK_CRON = '* * * * *';
|
||||
// 钉钉 text 上限 20000 字节;留 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;
|
||||
|
||||
function pad2(value) {
|
||||
@ -103,66 +75,6 @@ function chinaHourMinute(date = new Date()) {
|
||||
}).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 消息会把一条明细劈成两行,看着像两条异常。
|
||||
@ -620,9 +532,8 @@ async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', opt
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度:每分钟问一次 `resolveScheduledPushDecision`。
|
||||
* 调度:每分钟比对「北京时间 HH:mm == 配置的 sendTime」。
|
||||
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
|
||||
* 之所以不是「HH:mm 严格相等」:那样只给一次机会,09:10 撞上业务库连不上就整天不推(11-11 实况)。
|
||||
*/
|
||||
function startWarehouseLogisticsExceptionNotifyScheduler() {
|
||||
const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true';
|
||||
@ -631,7 +542,6 @@ function startWarehouseLogisticsExceptionNotifyScheduler() {
|
||||
return;
|
||||
}
|
||||
const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
||||
let windowClosedLoggedDate = '';
|
||||
cron.schedule(expression, () => {
|
||||
if (tickRunning) return;
|
||||
tickRunning = true;
|
||||
@ -640,32 +550,8 @@ function startWarehouseLogisticsExceptionNotifyScheduler() {
|
||||
const now = new Date();
|
||||
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
|
||||
if (!config.enabled) return;
|
||||
const state = await loadWarehouseLogisticsExceptionNotifyState();
|
||||
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 }
|
||||
);
|
||||
if (chinaHourMinute(now) !== config.sendTime) return;
|
||||
await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now });
|
||||
} catch (error) {
|
||||
console.error('[WleNotify] 定时检查失败:', error.message || error);
|
||||
} finally {
|
||||
@ -674,15 +560,10 @@ function startWarehouseLogisticsExceptionNotifyScheduler() {
|
||||
})();
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
console.log(
|
||||
`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送,`
|
||||
+ `失败后每 ${RETRY_INTERVAL_MINUTES} 分钟重试、最多 ${RETRY_WINDOW_MINUTES} 分钟内 (${TIMEZONE})`
|
||||
);
|
||||
console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_RETRY_INTERVAL_MINUTES,
|
||||
DEFAULT_RETRY_WINDOW_MINUTES,
|
||||
DEFAULT_TICK_CRON,
|
||||
SAFE_CONTENT_BYTES,
|
||||
TIMEZONE,
|
||||
@ -694,11 +575,9 @@ module.exports = {
|
||||
chinaDateKey,
|
||||
chinaHourMinute,
|
||||
groupRowsByWarehouse,
|
||||
minuteOfDay,
|
||||
pickDetailRows,
|
||||
cleanCell,
|
||||
pushWarehouseLogisticsExceptionNotify,
|
||||
resolveScheduledPushDecision,
|
||||
runWarehouseLogisticsExceptionNotifyJob,
|
||||
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||
};
|
||||
|
||||
@ -152,14 +152,6 @@ async function loadShippedPackages(batch) {
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
// 🔥 2026-11-14 修:原来调这里写的是 `{ timeoutMs: 240000 }` —— **这个参数没人读**。
|
||||
// mssql 12.x 上请求级 request.timeout 不生效,超时只能靠池配置级 requestTimeout,
|
||||
// 而共享池(query())的池级超时在 pool 创建时就定死了,改不了。结果是「想给 4 分钟」
|
||||
// 实际一路按默认 60s 掐断(11-14 实测全表 MAX 已经跑不进 60s → 探测永远返回空)。
|
||||
// 真正生效的写法只有 querySlow(临时独立连接)+ connectRequestTimeoutMs。
|
||||
const SOURCE_LAST_UPDATE_TIMEOUT_MS = Number(process.env.CANGKU_WLE_SOURCE_LAST_UPDATE_TIMEOUT_MS)
|
||||
|| 180000;
|
||||
|
||||
/**
|
||||
* 整张镜像表最后被灌库的时刻(表级新鲜度)。
|
||||
*
|
||||
@ -171,18 +163,16 @@ const SOURCE_LAST_UPDATE_TIMEOUT_MS = Number(process.env.CANGKU_WLE_SOURCE_LAST_
|
||||
*
|
||||
* 成本:全表 `MAX(query_time)` 在一个 402 万行的视图上实测 **13.5s**(谓词下推差),
|
||||
* 所以**只在窗口 0 行时才调**(正常批次一行都不用付这笔钱),结果随主结果进 5 分钟缓存。
|
||||
* ⚠️ 11-14 实测已退化到 60s 以上,所以超时上限 180s + maxAttempts=1
|
||||
* (超时就退回「不知道」,重试只会把等待翻倍 —— 原来 query() 默认 2 次 × 60s 白等)。
|
||||
*
|
||||
* @returns {Promise<string>} 'YYYY-MM-DD HH:mm:ss',取不到返回空串
|
||||
*/
|
||||
async function loadSourceLastUpdate() {
|
||||
try {
|
||||
const rows = await cloudDatabase.querySlow(
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT MAX(e.query_time) AS max_q
|
||||
FROM dbo.jst_order_express_details e WITH (NOLOCK)`,
|
||||
{},
|
||||
{ connectRequestTimeoutMs: SOURCE_LAST_UPDATE_TIMEOUT_MS, maxAttempts: 1 }
|
||||
{ timeoutMs: 240000 }
|
||||
);
|
||||
return formatDateTime(rows?.[0]?.max_q);
|
||||
} catch (err) {
|
||||
@ -198,15 +188,8 @@ async function loadSourceLastUpdate() {
|
||||
// · 只查「本来就会报异常」的 oId(正常单不查,量级从 9800 降到几十);
|
||||
// · 两套店铺表**并发**查,总耗时取较慢的那条(~8.5s),而不是相加的 13s;
|
||||
// · 结果进 5 分钟结果缓存,页面自动刷新时基本命中缓存。
|
||||
//
|
||||
// 🔥 2026-11-14 修:超时从「名义 240s」改成「真 20s,且不重试」。
|
||||
// 原来写 `{ timeoutMs: 240000 }`(无效参数),实际按共享池 60s 掐;
|
||||
// 超时后 query() 还把它当可重试错误 —— resetPool + 再查一次,**再多等 60s**。
|
||||
// 11-13 09:10 那轮推送就是这么被拖掉 2 分钟的(日志:售后拦截 60s 超时 → 重连重试)。
|
||||
// 售后表跟不上时业务影响只是「少剔几单」,不值得为它把整批核验拖进超时窗口:
|
||||
// 2 倍实测余量(8.5s → 20s)够用,超了就降级不剔(catch 里已有兜底)。
|
||||
const INTERCEPT_QUERY_BATCH = 500;
|
||||
const INTERCEPT_QUERY_TIMEOUT_MS = 20000;
|
||||
const INTERCEPT_QUERY_TIMEOUT_MS = 240000;
|
||||
|
||||
/**
|
||||
* 找出「现场拦截、货已被售后仓签收」的订单(业务 2026-10-23 口径)。
|
||||
@ -236,7 +219,7 @@ async function loadInterceptedOrders(rows) {
|
||||
const oIds = ids.slice(i, i + INTERCEPT_QUERY_BATCH).join(',');
|
||||
for (const source of registry.INTERCEPT_AFTERSALE_SOURCES) {
|
||||
tasks.push((async () => {
|
||||
const hits = await cloudDatabase.querySlow(
|
||||
const hits = await cloudDatabase.query(
|
||||
`SELECT h.o_id
|
||||
FROM ${source.header} h WITH (NOLOCK)
|
||||
JOIN ${source.item} it WITH (NOLOCK) ON it.as_id = ${source.headerAsId}
|
||||
@ -245,8 +228,7 @@ async function loadInterceptedOrders(rows) {
|
||||
GROUP BY h.o_id
|
||||
HAVING SUM(ISNULL(it.r_qty, 0)) > 0`,
|
||||
{},
|
||||
// maxAttempts: 1 —— 超时就在 catch 里降级「不剔除」,别让重连重试再白等一轮
|
||||
{ connectRequestTimeoutMs: INTERCEPT_QUERY_TIMEOUT_MS, maxAttempts: 1 }
|
||||
{ timeoutMs: INTERCEPT_QUERY_TIMEOUT_MS }
|
||||
);
|
||||
return hits || [];
|
||||
})().catch((err) => {
|
||||
|
||||
@ -145,13 +145,11 @@ test('状态为空时按运单号区分:有号=未揽收,无号=无物流单
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
|
||||
function stubDb(rows, options = {}) {
|
||||
const calls = { times: 0, sql: [], params: [], options: [], api: [] };
|
||||
const makeHandler = (api) => async (sql, params, opts) => {
|
||||
const calls = { times: 0, sql: [], params: [] };
|
||||
const handler = async (sql, params) => {
|
||||
calls.times += 1;
|
||||
calls.sql.push(String(sql));
|
||||
calls.params.push(params || {});
|
||||
calls.options.push(opts || {});
|
||||
calls.api.push(api);
|
||||
// 售后拦截判定会额外查两张售后表 —— 按表名分流,别把物流行当成售后行
|
||||
if (/jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(String(sql))) {
|
||||
return options.interceptRows || [];
|
||||
@ -166,7 +164,7 @@ function stubDb(rows, options = {}) {
|
||||
id: dbPath,
|
||||
filename: dbPath,
|
||||
loaded: true,
|
||||
exports: { query: makeHandler('query'), querySlow: makeHandler('querySlow') },
|
||||
exports: { query: handler, querySlow: handler },
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
@ -700,21 +698,13 @@ test('窗口 0 行 + 数据源停更 → emptyWindow/sourceStale,hint 明说
|
||||
|
||||
test('窗口 0 行但数据源是新的 → 仍然提示「0 条记录」,不能静默全绿', async () => {
|
||||
const nov2 = new Date(2026, 10, 2, 10, 0, 0);
|
||||
// ⚠️ sourceLastUpdate 必须是「相对真实现在」的时间,不能像原来那样硬编码 2026-11-02 09:58。
|
||||
// 服务层算 ageMinutes 用的是 registry.beijingNow()(真实时钟),不是测试传进来的 now ——
|
||||
// 硬编码的日期过一天就变成「停更 24h」,这个用例第二天起就必挂(2026-11-14 实测挂在 main 上)。
|
||||
const freshText = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
}).format(new Date(Date.now() - 3 * 60 * 1000));
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||
assert.equal(data.freshness.emptyWindow, true);
|
||||
assert.equal(data.freshness.sourceStale, false, '表是新的,不该报停更');
|
||||
assert.match(data.freshness.hint, /0 条发货记录/, '真 0 单也要说清是「库里没有记录」');
|
||||
assert.match(data.freshness.hint, /数据源本身正常/);
|
||||
}, [], { sourceMaxQueryTime: freshText });
|
||||
}, [], { sourceMaxQueryTime: '2026-11-02 09:58:00' });
|
||||
});
|
||||
|
||||
test('窗口有行时不做全表探测(省掉 13.5s 的表级 MAX)', async () => {
|
||||
@ -725,37 +715,3 @@ test('窗口有行时不做全表探测(省掉 13.5s 的表级 MAX)', async
|
||||
assert.equal(calls.sql.filter((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s)).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ⑨ 超时参数必须「真生效」(2026-11-14)──────────────────────────────────
|
||||
// 起因:11-13 09:10 那轮钉钉推送 300s 超时没推出去。查代码发现两处写了
|
||||
// `{ timeoutMs: 240000 }` 想「给足 4 分钟」,而 cloudDatabase **根本不读 timeoutMs**
|
||||
// (mssql 12.x 请求级 request.timeout 不生效,只能靠池配置级 requestTimeout,
|
||||
// 共享池的池级超时创建时就定死了)—— 实际一路按 60s 掐断,
|
||||
// 超时后 query() 还当可重试错误重连再查一次,日志里白等了约 2 分钟。
|
||||
test('售后拦截查询走 querySlow + 20s 短超时 + 不重试', async () => {
|
||||
await withStub(async (svc, calls) => {
|
||||
await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
const idx = calls.sql.findIndex((s) => /jst_returnAndRefundInfo|jst_qmcust_refundlist/u.test(s));
|
||||
assert.ok(idx >= 0, '这批有异常单,应该去查售后表');
|
||||
assert.equal(calls.api[idx], 'querySlow', '必须走 querySlow:query() 的池级超时调用方改不了');
|
||||
assert.equal(calls.options[idx].connectRequestTimeoutMs, 20000, '2 倍实测余量(8.5s → 20s)就够');
|
||||
assert.equal(calls.options[idx].maxAttempts, 1, '超时就降级「不剔除」,别重连重试白等一轮');
|
||||
assert.equal(calls.options[idx].timeoutMs, undefined, 'timeoutMs 是无效参数,不许再写');
|
||||
}, rows, { interceptRows: [{ o_id: 1 }] });
|
||||
});
|
||||
|
||||
test('空窗口的表级 MAX 探测也走 querySlow(共享池 60s 跑不动全表)', async () => {
|
||||
const nov2 = new Date(2026, 10, 2, 10, 0, 0);
|
||||
await withStub(async (svc, calls) => {
|
||||
await svc.getLogisticsExceptions({ date: '2026-11-02', now: nov2, limit: 100 });
|
||||
const idx = calls.sql.findIndex((s) => /MAX\(\s*e\.query_time\s*\)/iu.test(s));
|
||||
assert.ok(idx >= 0, '空窗口要探测表级最后灌库时刻');
|
||||
assert.equal(calls.api[idx], 'querySlow');
|
||||
assert.ok(
|
||||
Number(calls.options[idx].connectRequestTimeoutMs) >= 120000,
|
||||
`全表 MAX 实测已超 60s,必须放宽,实际 ${calls.options[idx].connectRequestTimeoutMs}`
|
||||
);
|
||||
assert.equal(calls.options[idx].maxAttempts, 1, '探测失败退回「不知道」即可,不值得重试');
|
||||
assert.equal(calls.options[idx].timeoutMs, undefined);
|
||||
}, [], { sourceMaxQueryTime: '2026-11-01 07:55:04' });
|
||||
});
|
||||
|
||||
@ -607,137 +607,3 @@ test('快照字段缺失(老缓存 / 接口未返回)→ 不能崩,也不
|
||||
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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user