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