Compare commits

..

No commits in common. "main" and "yanhui-wle-notify-retry-1111" have entirely different histories.

5 changed files with 10 additions and 211 deletions

View File

@ -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) {

View File

@ -22,9 +22,6 @@
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。
* ⑤ 数据新鲜度必须进正文(2026-11-11 加):只要算出异常,就要说明物流快照
* 是否覆盖本轮核验时刻、是否过旧 —— 否则「990 单 100% 异常」到底是真异常
* 还是镜像表停在昨天,群里看不出来。见 `buildFreshnessWarning`。
*
* 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。
*/
@ -299,51 +296,6 @@ function buildTypeLine(data = {}) {
.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) {
const base = String(pageUrl || DEFAULT_PAGE_URL);
if (!verifyDate) return base;
@ -381,15 +333,10 @@ function buildNotifyText(data = {}, options = {}) {
const typeLine = buildTypeLine(data);
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 单有两种完全不同的含义,不能都写「全部正常」——
// ① 数据源没同步(镜像表窗口 0 行)→ 0 单是假的,写「全部正常」等于给群里发假绿;
// ② 当天确实没发货 → 0 单是真的,那也不叫「正常」,叫「无发货可核」。
const freshness = data.freshness || {};
if (totalPackages === 0) {
if (freshness.emptyWindow && freshness.sourceStale) {
const hours = Number(freshness.sourceAgeMinutes) > 0
@ -688,7 +635,6 @@ module.exports = {
TIMEZONE,
buildCenterLines,
buildDetailLine,
buildFreshnessWarning,
buildNotifyText,
buildTypeLine,
chinaDateKey,

View File

@ -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) => {

View File

@ -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' });
});

View File

@ -541,71 +541,6 @@ test('数据源正常但当天 0 条记录 → 说明是「库里没有记录」
assert.match(built.text, /请到聚水潭核对/);
assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更');
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)──────────────