11-02 09:00 那批推到钉钉群的是「已发货 0 单 / 0 个包裹,异常 0 个包裹(0%)」, 业务问「怎么可能 0 单」。查库确认:物流镜像表 jst_order_express_details 最后灌库停在 2026-11-01 07:55,11-01 当天 12 个发货仓一条发货记录都没有 (订单表 jst_Orders 同日有 4601 单发货,说明货是真发了,只是物流没进库)。 根因不是口径,是「0 单没有告警」这个设计缺陷:freshness 只用本批窗口内行的 query_time 算,窗口一行都没有时 snapshotAt 为空串 → stale=false、hint='', 于是「上游没同步」和「当天真没发货」在页面和钉钉里长得完全一样,还写「全部正常」。 改动 - service:窗口 0 行时补查一次表级 MAX(query_time)(全表 13.5s,只在空窗口付这笔钱, 随主结果进 5 分钟缓存),新增 freshness.emptyWindow / sourceLastUpdate / sourceAgeMinutes / sourceStale,并按「真停更 / 表新但当天无记录 / 取不到时间」 三种情况给不同 hint。 - 钉钉正文:0 条时不再无脑写「本批无转运异常,全部正常」;真停更写 「⚠️ 物流数据未同步:镜像表最后更新 …(已 N 小时未同步)… 0 单不代表当天没发货」, 表新但当天无记录则说明「库里 0 条发货记录(数据源正常),请到聚水潭核对」。 - 前端:emptyWindow 走红条(原来只判 coversDeadline/stale,空窗口不告警), 发货卡片副标改为「数据未同步,见上方红条」。 - 前端顺带修:钉钉消息链接带 ?date=YYYY-MM-DD,但页面从不读这个参数, 点进去永远显示「今天这批」;现按 URL 初始化核验日。 验证 - npm --prefix backend run check:505 tests / 503 pass;新增 5 个用例 (停更 hint、表新但 0 条、有行时不付全表 MAX、推送文案两态)全过。 既有失败 2 个与本任务无关:cloudDatabaseConfigCache 负缓存 TTL、 resilientSecretStore 的 Windows 0600 权限断言。 - 生产库实跑(本地 4002,直连 JSReportReplica): date=2026-11-02 → emptyWindow=true、sourceLastUpdate=2026-11-01 07:55:04、 sourceAgeMinutes≈1590、hint 正确;date=2026-11-01 → 4390 单 / 9 异常、 emptyWindow=false,原有「未覆盖核验时刻」提示不变。 - 钉钉 dryRun 正文实测:前 3 行即带「⚠️ 物流数据未同步 … 0 单不代表当天没发货」。 - Playwright 真渲染(5173): ?date=2026-11-02 → 红条 + 「数据未同步,见上方红条」,0 pageerror; ?date=2026-11-01 → 4,390 单 / 9 异常正常出表,且 URL 参数已生效。 - 前端 eslint 干净,vite build 通过。 风险与回退 - 只增字段与提示,不改任何统计口径;packageCount/shippedOrders 计算逻辑未动。 - 空窗口多一条全表 MAX 查询(13.5s),已用 5 分钟缓存兜住,且只在异常路径触发。 - 回退:单文件 revert 本提交即可,无数据结构变更。
545 lines
24 KiB
JavaScript
545 lines
24 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 「仓储中心物流转运异常」每天推文本到钉钉群 —— 正文组装与通道回归测试
|
||
* ────────────────────────────────────────────────────────────────────────────
|
||
* 这里只测四件容易悄悄坏掉的事:
|
||
* ① 群消息正文的数字与明细(业务照着这条消息干活,数字错了没人能发现);
|
||
* ② 0 条 / 明细条数上限 / 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
|
||
* ③ webhook 地址与加签(sign 算错就是 310000,肉眼看不出来);
|
||
* ④ 钉钉报错必须原样透出 errcode/errmsg(业务排错全靠这个码)。
|
||
* 不测数据库与真实网络:settings / job 的 DB 分支在接口层验证。
|
||
*/
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const crypto = require('crypto');
|
||
|
||
const job = require('../src/jobs/warehouseLogisticsExceptionNotifyJob');
|
||
const sender = require('../src/services/dingtalkRobotWebhookSender');
|
||
const settings = require('../src/services/warehouseLogisticsExceptionNotifySettings');
|
||
|
||
function sampleData(overrides = {}) {
|
||
return {
|
||
batch: {
|
||
label: '10-22 09:00 核验 · 10-21 发货',
|
||
verifyDate: '2026-10-22',
|
||
shipDate: '2026-10-21',
|
||
deadline: '2026-10-22 09:00:00',
|
||
},
|
||
summary: {
|
||
shippedOrders: 9886,
|
||
packageCount: 9886,
|
||
exceptionOrders: 143,
|
||
exceptionPackages: 144,
|
||
exceptionRate: 1.4,
|
||
centerCount: 9,
|
||
byType: { PICKED_NO_TRANSIT: 137, NOT_PICKED: 7 },
|
||
},
|
||
centers: [
|
||
{ key: 'sn', name: '睢宁仓', shippedOrders: 1200, exceptionOrders: 1, exceptionRate: 0.1 },
|
||
{ key: 'cq', name: '崔桥工厂仓', shippedOrders: 3000, exceptionOrders: 2, exceptionRate: 0.1 },
|
||
{ key: 'hl', name: '横林二仓', shippedOrders: 800, exceptionOrders: 0, exceptionRate: 0 },
|
||
],
|
||
types: [
|
||
{ key: 'PICKED_NO_TRANSIT', label: '已揽收未中转', desc: '揽收后没有下一步中转记录' },
|
||
{ key: 'NOT_PICKED', label: '未揽收', desc: '快递还没来揽' },
|
||
],
|
||
exclusions: {
|
||
carriers: ['唯品顺丰', '顺丰', '京喜京东'],
|
||
conditionalCarriers: [{ keyword: '睢宁中通', keepCenters: ['睢宁仓'] }],
|
||
orderStatusKeywords: ['取消', '作废'],
|
||
orderLabelKeywords: ['发货后取消'],
|
||
orderTypeLabels: ['刷单', '转空包'],
|
||
carrierPackages: 12,
|
||
voidedPackages: 3,
|
||
nonRealPackages: 5,
|
||
},
|
||
rows: [
|
||
{
|
||
centerKey: 'cq',
|
||
warehouseName: '崔桥工厂仓',
|
||
oId: '9845001',
|
||
soId: '3318',
|
||
shopName: '京东自营',
|
||
carrier: '京东快递',
|
||
trackingNo: 'JD1',
|
||
logisticStatus: '已揽收',
|
||
exceptionLabel: '已揽收未中转',
|
||
exceptionDesc: '揽收后没有下一步中转记录',
|
||
sendTime: '2026-10-21 16:00:00',
|
||
lastLogisticTime: '2026-10-21 21:00:00',
|
||
pendingHours: 17,
|
||
lastLogisticInfo: '已揽收',
|
||
},
|
||
{
|
||
centerKey: 'sn',
|
||
warehouseName: '睢宁仓',
|
||
oId: '9844248',
|
||
soId: '3316',
|
||
shopName: '天猫旗舰店',
|
||
carrier: '睢宁德邦',
|
||
trackingNo: '',
|
||
logisticStatus: '',
|
||
exceptionLabel: '未揽收',
|
||
exceptionDesc: '快递还没来揽',
|
||
sendTime: '2026-10-21 14:22:10',
|
||
lastLogisticTime: '',
|
||
pendingHours: null,
|
||
lastLogisticInfo: '',
|
||
},
|
||
],
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
test('群消息正文:抬头带「异常」关键词、数字与分类型、按仓只列有异常的仓', () => {
|
||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30, pageUrl: 'https://ck.yydd.ai:8443/p' });
|
||
const text = built.text;
|
||
|
||
// 钉钉「自定义关键词」安全设置一般配「异常」,抬头必须含这两个字
|
||
assert.match(text, /【仓储物流转运异常】/);
|
||
assert.match(text, /10-22 09:00 核验 · 10-21 发货/);
|
||
assert.match(text, /已发货 9886 单 \/ 9886 个包裹,异常 144 个包裹(1\.4%)/);
|
||
// 分类型按数量降序,且必须是可读标签(不是 PICKED_NO_TRANSIT 这种 key)
|
||
assert.match(text, /类型:已揽收未中转 137 · 未揽收 7/);
|
||
// 剔除数量 = 12 + 3 + 5
|
||
assert.match(text, /已剔除 20 个包裹/);
|
||
// 按仓只列有异常的:睢宁 1、崔桥 1;横林二仓 0 条不能出现
|
||
assert.match(text, /按仓(异常包裹):/);
|
||
assert.match(text, /睢宁仓 1 个(发货 1200 单)/);
|
||
assert.match(text, /崔桥工厂仓 1 个(发货 3000 单)/);
|
||
assert.ok(!text.includes('横林二仓'), '没有异常的仓不该占行');
|
||
// 页码链接带核验日
|
||
assert.match(text, /查看全部 \/ 导出 Excel:https:\/\/ck\.yydd\.ai:8443\/p\?date=2026-10-22/);
|
||
|
||
assert.equal(built.rowCount, 144);
|
||
assert.equal(built.listedCount, 2);
|
||
assert.equal(built.truncated, false);
|
||
});
|
||
|
||
test('明细行:仓|快递单号|快递|异常类型(4 段,不带发货时间和待处理时长),字段里换行要压平', () => {
|
||
const line = job.buildDetailLine({
|
||
warehouseName: '崔桥工厂仓',
|
||
oId: '9845001',
|
||
trackingNo: 'JDVA00123456789',
|
||
carrier: '京东快递',
|
||
exceptionLabel: '已揽收未中转',
|
||
sendTime: '2026-10-21 16:00:00',
|
||
pendingHours: 17.25,
|
||
lastLogisticInfo: '第一行\n第二行',
|
||
}, 3);
|
||
|
||
assert.equal(line, '3. 崔桥工厂仓|JDVA00123456789|京东快递|已揽收未中转');
|
||
// 业务要求:第二段是快递单号,不是内部单号
|
||
assert.ok(!line.includes('9845001'), '明细行不该再显示内部单号');
|
||
// 业务要求:明细不带发货时间(哪怕传了 sendTime 也不能出现在消息里)
|
||
assert.ok(!line.includes('10-21'), '明细行不该再带发货时间');
|
||
assert.ok(!line.includes('16:00'), '明细行不该再带发货时间');
|
||
// 业务要求:末尾的待处理小时数也去掉(哪怕传了 pendingHours 也不能出现在消息里)
|
||
assert.ok(!line.includes('17.3h'), '明细行不该再带待处理时长');
|
||
assert.equal(line.split('|').length, 4, '明细必须正好 4 段');
|
||
|
||
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
|
||
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
|
||
// 未揽收/无物流单没有快递单号 → 退回内部单号(否则群里这条无法定位)
|
||
assert.equal(
|
||
job.buildDetailLine({ oId: '9845002', carrier: '中通' }, 1),
|
||
'1. 未知仓|9845002|中通|异常'
|
||
);
|
||
// 空值兜底:不能让消息里出现「undefined|null」
|
||
assert.equal(
|
||
job.buildDetailLine({}, 1),
|
||
'1. 未知仓|无单号|未知快递|异常'
|
||
);
|
||
});
|
||
|
||
test('明细按仓聚集:同仓相邻、组间按异常数降序、组内保持原顺序(时长降序)', () => {
|
||
const rows = [
|
||
{ warehouseName: '睢宁仓', trackingNo: 'SN1', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ1', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||
{ warehouseName: '睢宁仓', trackingNo: 'SN2', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||
{ warehouseName: '山东青岗集仓', trackingNo: 'SD1', carrier: '山东中通', exceptionLabel: '已揽收未中转' },
|
||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ2', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ3', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||
];
|
||
|
||
const grouped = job.groupRowsByWarehouse(rows);
|
||
// 崔桥 3 条 > 睢宁 2 条 > 青岗集 1 条,组间按异常数降序
|
||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['CQ1', 'CQ2', 'CQ3', 'SN1', 'SN2', 'SD1']);
|
||
// 不丢行、不改行
|
||
assert.equal(grouped.length, rows.length);
|
||
|
||
const text = job.buildNotifyText(sampleData({ rows }), { maxDetailRows: 30 }).text;
|
||
// 只取「明细前 N 条」块里的行(上面「按仓」块也是 `1. xxx` 格式,别混进来)
|
||
const detailBlock = text.split('明细前')[1] || '';
|
||
const detailLines = detailBlock.split('\n').filter((line) => /^\d+\. /.test(line));
|
||
const warehouses = detailLines.map((line) => line.split('|')[0].replace(/^\d+\. /, ''));
|
||
|
||
// 同仓必须相邻:切成连续段后不应出现重复仓名
|
||
const runs = warehouses.filter((name, i) => name !== warehouses[i - 1]);
|
||
assert.equal(new Set(runs).size, runs.length, `同仓没有排在一起:${warehouses.join(' / ')}`);
|
||
assert.deepEqual(warehouses, ['崔桥工厂仓', '崔桥工厂仓', '崔桥工厂仓', '睢宁仓', '睢宁仓', '山东青岗集仓']);
|
||
// 编号仍然全局连续,不能每组从 1 开始(否则群里看到重复序号)
|
||
assert.deepEqual(detailLines.map((line) => line.match(/^(\d+)\./)[1]), ['1', '2', '3', '4', '5', '6']);
|
||
});
|
||
|
||
test('明细按仓聚集:仓名缺失的行归到「未知仓」一组,不会被拆散', () => {
|
||
const rows = [
|
||
{ warehouseName: '', trackingNo: 'A1' },
|
||
{ warehouseName: '睢宁仓', trackingNo: 'B1' },
|
||
{ trackingNo: 'A2' },
|
||
];
|
||
const grouped = job.groupRowsByWarehouse(rows);
|
||
// 未知仓 2 条(含 undefined)> 睢宁 1 条
|
||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['A1', 'A2', 'B1']);
|
||
});
|
||
|
||
test('明细额度按仓分配:大仓不能吃满,每仓至少 1 条', () => {
|
||
// 真数据就是这样:青岗集一家占了大头(10-22 实测 140 条里前 40 条全是它)
|
||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||
warehouseName: name,
|
||
trackingNo: `${name}-${i}`,
|
||
}));
|
||
const rows = [
|
||
...make('青岗集仓', 100),
|
||
...make('崔桥工厂仓', 20),
|
||
...make('睢宁仓', 8),
|
||
...make('新苏北仓', 6),
|
||
...make('横林二仓', 6),
|
||
];
|
||
|
||
const picked = job.pickDetailRows(rows, 40);
|
||
assert.equal(picked.length, 40, '额度必须正好用满(140 条 > 40)');
|
||
|
||
const counts = picked.reduce((acc, row) => {
|
||
acc[row.warehouseName] = (acc[row.warehouseName] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
// 每个仓都要露面,否则群里那个仓的人看不到自己的单
|
||
for (const name of ['青岗集仓', '崔桥工厂仓', '睢宁仓', '新苏北仓', '横林二仓']) {
|
||
assert.ok(counts[name] >= 1, `${name} 一条明细都没分到`);
|
||
}
|
||
// 大仓仍然拿得多,但不能吃满额度
|
||
assert.ok(counts['青岗集仓'] < 40, '大仓不该独占全部额度');
|
||
assert.ok(counts['青岗集仓'] > counts['崔桥工厂仓'], '异常多的仓应分到更多明细');
|
||
|
||
// 同仓仍然相邻
|
||
const names = picked.map((row) => row.warehouseName);
|
||
const runs = names.filter((name, i) => name !== names[i - 1]);
|
||
assert.equal(runs.length, new Set(runs).size, `同仓被拆散:${names.join(',')}`);
|
||
});
|
||
|
||
test('明细额度比仓数还少时:按异常数从多到少,每仓只取 1 条', () => {
|
||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||
warehouseName: name,
|
||
trackingNo: `${name}-${i}`,
|
||
}));
|
||
const rows = [...make('大仓', 10), ...make('中仓', 5), ...make('小仓', 2)];
|
||
const picked = job.pickDetailRows(rows, 2);
|
||
assert.deepEqual(picked.map((row) => row.trackingNo), ['大仓-0', '中仓-0']);
|
||
// 额度 0 / 超额度的边界
|
||
assert.deepEqual(job.pickDetailRows(rows, 0), []);
|
||
assert.equal(job.pickDetailRows(rows, 99).length, 17);
|
||
});
|
||
|
||
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
|
||
const data = sampleData({
|
||
rows: [],
|
||
summary: {
|
||
...sampleData().summary,
|
||
exceptionOrders: 0,
|
||
exceptionPackages: 0,
|
||
exceptionRate: 0,
|
||
byType: {},
|
||
centerCount: 0,
|
||
},
|
||
exclusions: {},
|
||
});
|
||
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||
assert.match(built.text, /本批无转运异常,全部正常/);
|
||
assert.match(built.text, /异常 0 个包裹(/);
|
||
assert.ok(!built.text.includes('明细前'), '0 条时不该出现明细标题');
|
||
assert.equal(built.rowCount, 0);
|
||
assert.equal(built.listedCount, 0);
|
||
});
|
||
|
||
test('明细条数受配置控制:0 = 只发汇总,超量只列前 N 条并提示剩余', () => {
|
||
const zero = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
|
||
assert.ok(!/明细前 \d+ 条/.test(zero.text), 'maxDetailRows=0 时不要明细块');
|
||
assert.match(zero.text, /明细共 144 条,消息里不放,请点下面链接查看/);
|
||
|
||
const one = job.buildNotifyText(sampleData(), { maxDetailRows: 1 });
|
||
assert.equal(one.listedCount, 1);
|
||
assert.ok(one.text.includes('1. 崔桥工厂仓'), '只列第一条');
|
||
assert.ok(!one.text.includes('2. 睢宁仓'), '第二条应被截掉');
|
||
assert.match(one.text, /其余 143 条见页面/);
|
||
});
|
||
|
||
test('明细太多时按字节精简:不超 20000 字节上限,且不切断半个汉字', () => {
|
||
// 造 60 条很长的明细(仓名 144 字 ≈ 432 字节/行),必然超过 text 上限
|
||
const longName = '崔桥工厂半成品包装项目仓'.repeat(12);
|
||
const rows = Array.from({ length: 60 }, (_item, index) => ({
|
||
centerKey: 'cq',
|
||
warehouseName: longName,
|
||
oId: `9845${String(index).padStart(3, '0')}`,
|
||
carrier: '中通快递',
|
||
exceptionLabel: '已揽收未中转',
|
||
sendTime: '2026-10-21 16:00:00',
|
||
pendingHours: 17,
|
||
}));
|
||
const data = sampleData({
|
||
rows,
|
||
summary: { ...sampleData().summary, exceptionPackages: rows.length, exceptionOrders: rows.length },
|
||
});
|
||
|
||
const built = job.buildNotifyText(data, { maxDetailRows: 50 });
|
||
assert.ok(built.bytes <= sender.MAX_CONTENT_BYTES, `实得 ${built.bytes} 字节,超了上限`);
|
||
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, '应留出安全余量');
|
||
assert.equal(built.truncated, true, '应标记为已精简');
|
||
assert.ok(built.listedCount < 50, '应自动少列几条');
|
||
assert.ok(!built.text.includes('\uFFFD'), '不能出现被截断产生的乱码');
|
||
// 削掉明细后仍保留抬头和链接
|
||
assert.match(built.text, /【仓储物流转运异常】/);
|
||
assert.match(built.text, /查看全部/);
|
||
});
|
||
|
||
test('按字节截断工具:中文按 3 字节算,不切半个字', () => {
|
||
assert.deepEqual(sender.truncateBytes('abc', 10), { text: 'abc', truncated: false });
|
||
// 「中」= 3 字节:10 字节最多放 3 个字(9 字节)
|
||
const cut = sender.truncateBytes('中文字符', 10);
|
||
assert.equal(cut.text, '中文字');
|
||
assert.equal(cut.truncated, true);
|
||
assert.ok(Buffer.byteLength(cut.text, 'utf8') <= 10);
|
||
});
|
||
|
||
test('时区:容器是 UTC,推送时刻仍按北京时间判(09:10 不能变成 01:10)', () => {
|
||
// 2026-10-22T01:10Z = 北京时间 09:10
|
||
assert.equal(job.chinaHourMinute(new Date('2026-10-22T01:10:00Z')), '09:10');
|
||
assert.equal(job.chinaDateKey(new Date('2026-10-22T01:10:00Z')), '2026-10-22');
|
||
// 北京时间 00:30(UTC 前一天 16:30)→ 日期必须是新的一天
|
||
assert.equal(job.chinaDateKey(new Date('2026-10-21T16:30:00Z')), '2026-10-22');
|
||
assert.equal(job.chinaHourMinute(new Date('2026-10-21T16:30:00Z')), '00:30');
|
||
});
|
||
|
||
test('推送时间 / 明细条数:非法输入一律退回默认值,不能把定时任务配挂', () => {
|
||
assert.equal(settings.normalizeSendTime('08:05'), '08:05');
|
||
assert.equal(settings.normalizeSendTime('9:10'), '09:10');
|
||
assert.equal(settings.normalizeSendTime('25:00'), '09:10');
|
||
assert.equal(settings.normalizeSendTime(''), '09:10');
|
||
assert.equal(settings.normalizeSendTime(undefined), '09:10');
|
||
|
||
assert.equal(settings.normalizeMaxDetailRows(undefined), 30);
|
||
assert.equal(settings.normalizeMaxDetailRows(-5), 0);
|
||
assert.equal(settings.normalizeMaxDetailRows(999), 50);
|
||
assert.equal(settings.normalizeMaxDetailRows('12'), 12);
|
||
assert.equal(settings.normalizeMaxDetailRows('abc'), 30);
|
||
});
|
||
|
||
test('配置校验:只要有 Webhook 就算配好;加签密钥只回掩码', () => {
|
||
assert.deepEqual(settings.missingFields({ webhookUrl: '' }), ['Webhook 地址']);
|
||
assert.deepEqual(settings.missingFields({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }), []);
|
||
assert.equal(settings.isWarehouseLogisticsExceptionNotifyReady({}), false);
|
||
assert.equal(
|
||
settings.isWarehouseLogisticsExceptionNotifyReady({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }),
|
||
true,
|
||
'安全设置用「关键词」时不需要加签密钥'
|
||
);
|
||
|
||
const admin = settings.toAdminConfig({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t',
|
||
webhookSecret: 'SEC0123456789abcdef',
|
||
enabled: true,
|
||
});
|
||
assert.equal(admin.webhookSecretConfigured, true);
|
||
assert.ok(!JSON.stringify(admin).includes('SEC0123456789abcdef'), '明文加签密钥不能出现在返回体里');
|
||
assert.match(admin.webhookSecretPreview, /\*+/);
|
||
// Webhook 要完整回显:业务需要核对 access_token
|
||
assert.match(admin.webhookUrl, /access_token=t/);
|
||
});
|
||
|
||
test('Webhook 地址校验:必须是 robot/send 且带 access_token', () => {
|
||
const ok = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
|
||
assert.equal(sender.normalizeWebhookUrl(ok), ok);
|
||
|
||
assert.throws(() => sender.normalizeWebhookUrl(''), /请填写/);
|
||
assert.throws(() => sender.normalizeWebhookUrl('oapi.dingtalk.com/robot/send'), /不是合法 URL/);
|
||
// 常见的复制错误:贴成了企业内部应用的地址 / 少复制了路径
|
||
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/robot/send'), /缺少 access_token/);
|
||
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/topapi/robot/send?access_token=a'), /不像群机器人/);
|
||
});
|
||
|
||
test('加签:sign = urlEncode(base64(HmacSHA256(secret, `${ts}\\n${secret}`)))', () => {
|
||
const webhook = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
|
||
const secret = 'SECtestsecret';
|
||
const ts = 1700000000000;
|
||
|
||
const signed = new URL(sender.buildSignedUrl(webhook, secret, ts));
|
||
assert.equal(signed.searchParams.get('timestamp'), String(ts));
|
||
assert.equal(signed.searchParams.get('access_token'), 'abc123', '原参数要保留');
|
||
|
||
// 手工算一遍(顺序写错就是 310000,这条断言就是防这个)
|
||
const expected = crypto
|
||
.createHmac('sha256', secret)
|
||
.update(`${ts}\n${secret}`, 'utf8')
|
||
.digest('base64');
|
||
// searchParams 会自动 decode,所以这里拿到的是原始 base64(含 + / =)
|
||
assert.equal(signed.searchParams.get('sign'), expected);
|
||
|
||
// 没配密钥(安全设置用关键词/IP):只追加 timestamp,不带 sign
|
||
const plain = new URL(sender.buildSignedUrl(webhook, '', ts));
|
||
assert.equal(plain.searchParams.get('sign'), null);
|
||
assert.equal(plain.searchParams.get('timestamp'), String(ts));
|
||
});
|
||
|
||
// ── 钉钉通道:stub fetch 验请求形状与错误透传 ────────────────────────────────
|
||
|
||
function stubFetch(handler) {
|
||
const calls = [];
|
||
const original = global.fetch;
|
||
global.fetch = async (url, options = {}) => {
|
||
calls.push({ url: String(url), options });
|
||
const body = handler.body === undefined ? {} : handler.body;
|
||
return new Response(JSON.stringify(body), { status: handler.status || 200 });
|
||
};
|
||
return {
|
||
calls,
|
||
restore: () => { global.fetch = original; },
|
||
};
|
||
}
|
||
|
||
test('发文本:msgtype=text,加签参数拼在 URL 上,@所有人开关透传', async () => {
|
||
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
|
||
try {
|
||
const result = await sender.sendTextToWebhook({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=abc123',
|
||
secret: 'SECtestsecret',
|
||
content: '【仓储物流转运异常】测试',
|
||
atAll: true,
|
||
});
|
||
|
||
assert.equal(result.delivered, true);
|
||
assert.equal(result.errmsg, 'ok');
|
||
|
||
const call = stub.calls[0];
|
||
assert.equal(call.options.method, 'POST');
|
||
assert.match(call.options.headers['Content-Type'], /application\/json/);
|
||
// 加签后 URL 必须带 timestamp + sign
|
||
const url = new URL(call.url);
|
||
assert.ok(url.searchParams.get('timestamp'), '缺少 timestamp');
|
||
assert.ok(url.searchParams.get('sign'), '缺少 sign');
|
||
|
||
const body = JSON.parse(call.options.body);
|
||
assert.equal(body.msgtype, 'text');
|
||
assert.equal(body.text.content, '【仓储物流转运异常】测试');
|
||
assert.equal(body.at.isAtAll, true);
|
||
assert.deepEqual(body.at.atMobiles, []);
|
||
} finally {
|
||
stub.restore();
|
||
}
|
||
});
|
||
|
||
test('发文本:空内容不发请求(避免钉钉 400 把错误码埋掉)', async () => {
|
||
let touched = 0;
|
||
const original = global.fetch;
|
||
global.fetch = async () => { touched += 1; throw new Error('不应该发请求'); };
|
||
try {
|
||
await assert.rejects(
|
||
() => sender.sendTextToWebhook({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||
content: ' ',
|
||
}),
|
||
/内容为空/
|
||
);
|
||
assert.equal(touched, 0);
|
||
} finally {
|
||
global.fetch = original;
|
||
}
|
||
});
|
||
|
||
test('发文本:钉钉 errcode 原样透出(310000 关键词/加签不匹配是最常见的坑)', async () => {
|
||
const stub = stubFetch({ body: { errcode: 310000, errmsg: 'sign not match' } });
|
||
try {
|
||
await assert.rejects(
|
||
() => sender.sendTextToWebhook({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||
secret: 'SECwrong',
|
||
content: '【仓储物流转运异常】测试',
|
||
}),
|
||
(error) => {
|
||
assert.equal(error.code, 310000);
|
||
assert.match(error.message, /310000/);
|
||
assert.match(error.message, /sign not match/);
|
||
return true;
|
||
}
|
||
);
|
||
} finally {
|
||
stub.restore();
|
||
}
|
||
});
|
||
|
||
test('发文本:超长正文自动截断后仍会发出去(不是直接抛错)', async () => {
|
||
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
|
||
try {
|
||
const huge = '异常'.repeat(12000); // 24000 字 ≈ 72000 字节,远超上限
|
||
const result = await sender.sendTextToWebhook({
|
||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||
content: huge,
|
||
});
|
||
assert.equal(result.truncated, true);
|
||
assert.ok(result.bytes <= sender.MAX_CONTENT_BYTES);
|
||
|
||
const body = JSON.parse(stub.calls[0].options.body);
|
||
assert.ok(Buffer.byteLength(body.text.content, 'utf8') <= sender.MAX_CONTENT_BYTES);
|
||
} finally {
|
||
stub.restore();
|
||
}
|
||
});
|
||
|
||
// ── ⑤ 0 单不能用「全部正常」糊过去(2026-11-02)──────────────────────────
|
||
// 实况:11-02 09:00 那批推的是「0 单 / 0 个包裹,异常 0 个包裹」,业务以为真没发货,
|
||
// 其实是物流镜像表 11-01 07:55 后就没同步。群里绝不能出现「全部正常」这种假绿。
|
||
test('数据源停更导致 0 单 → 正文首部醒目告警,且不出现「全部正常」', () => {
|
||
const data = sampleData({
|
||
rows: [],
|
||
summary: {
|
||
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||
},
|
||
exclusions: {},
|
||
freshness: {
|
||
emptyWindow: true,
|
||
sourceLastUpdate: '2026-11-01 07:55:04',
|
||
sourceAgeMinutes: 1565,
|
||
sourceStale: true,
|
||
hint: '11-01 的发货记录在物流镜像表里一条都没有',
|
||
},
|
||
});
|
||
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||
assert.match(built.text, /物流数据未同步/);
|
||
assert.match(built.text, /镜像表最后更新 2026-11-01 07:55:04/);
|
||
assert.match(built.text, /已 26\.1 小时未同步/);
|
||
assert.match(built.text, /不代表当天没发货/);
|
||
assert.ok(!built.text.includes('全部正常'), '数据没进库时不能说「全部正常」');
|
||
// 抬头仍要带「异常」关键词,否则钉钉自定义关键词会拒收
|
||
assert.match(built.text, /【仓储物流转运异常】/);
|
||
});
|
||
|
||
test('数据源正常但当天 0 条记录 → 说明是「库里没有记录」,不说「全部正常」也不谎报停更', () => {
|
||
const data = sampleData({
|
||
rows: [],
|
||
summary: {
|
||
shippedOrders: 0, packageCount: 0, exceptionOrders: 0,
|
||
exceptionPackages: 0, exceptionRate: 0, centerCount: 0, byType: {},
|
||
},
|
||
exclusions: {},
|
||
freshness: { emptyWindow: true, sourceLastUpdate: '2026-11-02 09:58:00', sourceAgeMinutes: 2, sourceStale: false },
|
||
});
|
||
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||
assert.match(built.text, /在物流镜像表里 0 条发货记录/);
|
||
assert.match(built.text, /数据源本身正常/);
|
||
assert.match(built.text, /请到聚水潭核对/);
|
||
assert.ok(!built.text.includes('物流数据未同步'), '表是新的,不能谎报停更');
|
||
assert.ok(!built.text.includes('全部正常'), '0 条不能糊成「全部正常」');
|
||
});
|