diff --git a/backend/src/api/warehouseLogisticsException.js b/backend/src/api/warehouseLogisticsException.js index 24cee90..8aa3aa3 100644 --- a/backend/src/api/warehouseLogisticsException.js +++ b/backend/src/api/warehouseLogisticsException.js @@ -8,6 +8,14 @@ * date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天) * limit 明细条数上限(默认 500,最大 2000) * force 1 = 绕过 5 分钟结果缓存 + * GET /api/warehouse-logistics-exception/export + * 同 /list 口径的不分页明细(导出用,上限 5000) + * + * 每天 09:10 推 Excel 到钉钉群(2026-10-22 新增): + * GET /api/warehouse-logistics-exception/notify/settings 读配置(AppSecret 只回掩码) + * PUT /api/warehouse-logistics-exception/notify/settings 保存配置(AppSecret 留空=不变) + * POST /api/warehouse-logistics-exception/notify/run 立即推送一次(force / dryRun) + * GET /api/warehouse-logistics-exception/notify/state 上次推送结果 * * 挂载见 src/index.js:`/api/warehouse-logistics-exception` + authMiddleware */ @@ -20,6 +28,16 @@ const { const { CENTERS, EXCEPTION_TYPES, LOOKBACK_DAYS, VERIFY_HOUR, } = require('../services/warehouseLogisticsExceptionRegistry'); +const { + getWarehouseLogisticsExceptionNotifyAdminConfig, + loadWarehouseLogisticsExceptionNotifyState, + missingFields, + saveWarehouseLogisticsExceptionNotifyConfig, + getWarehouseLogisticsExceptionNotifyRuntimeConfig, +} = require('../services/warehouseLogisticsExceptionNotifySettings'); +const { + pushWarehouseLogisticsExceptionNotify, +} = require('../jobs/warehouseLogisticsExceptionNotifyJob'); const router = express.Router(); @@ -67,4 +85,89 @@ router.get('/meta', (_req, res) => { }); }); +// ── 每天推送到钉钉群 ──────────────────────────────────────────────────────── + +// 读配置:AppSecret 只回掩码 + 是否已配置,明文永不出后端 +router.get('/notify/settings', async (_req, res) => { + try { + const [config, state] = await Promise.all([ + getWarehouseLogisticsExceptionNotifyAdminConfig(), + loadWarehouseLogisticsExceptionNotifyState(), + ]); + res.json({ success: true, data: { ...config, lastState: state || {} } }); + } catch (error) { + sendError(res, error, '读取异常通知配置失败'); + } +}); + +// 保存配置;AppSecret 留空表示「沿用已存的」,不必每次重填 +router.put('/notify/settings', async (req, res) => { + try { + const data = await saveWarehouseLogisticsExceptionNotifyConfig(req.body || {}); + res.json({ success: true, data }); + } catch (error) { + const status = Number(error?.status) || 400; + console.error('[WarehouseLogisticsException] 保存通知配置失败:', error?.message); + res.status(status).json({ success: false, error: error.message || '保存异常通知配置失败' }); + } +}); + +router.get('/notify/state', async (_req, res) => { + try { + const state = await loadWarehouseLogisticsExceptionNotifyState(); + res.json({ success: true, data: state || {} }); + } catch (error) { + sendError(res, error, '读取推送状态失败'); + } +}); + +/** + * 立即推送一次(页面「立即推送」/「预览」/「测试通道」)。 + * body: + * dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径) + * probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通) + * force true = 忽略「今天已经推过」(默认手动触发就是 true,除非显式给 false) + * date 指定核验日 + */ +router.post('/notify/run', async (req, res) => { + try { + const body = req.body || {}; + const dryRun = body.dryRun === true || body.dryRun === 'true'; + const probe = body.probe === true || body.probe === 'true'; + const force = body.force === undefined ? true : (body.force === true || body.force === 'true'); + if (!dryRun) { + const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true }); + const missing = missingFields(config); + if (missing.length) { + res.status(400).json({ + success: false, + error: `请先补全并保存:${missing.join(' / ')}`, + data: { missingFields: missing }, + }); + return; + } + } + const result = await pushWarehouseLogisticsExceptionNotify({ + dryRun, + probe, + force, + date: body.date || undefined, + }); + // 查完这一轮记得让页面上的状态同步(lastState 由前端重新拉 settings 获取) + res.json({ success: true, data: result }); + } catch (error) { + const status = Number(error?.status) || 500; + console.error('[WarehouseLogisticsException] 推送失败:', { + message: error?.message, + code: error?.code, + dingtalk: error?.dingtalk, + }); + res.status(status).json({ + success: false, + error: error.message || '推送到钉钉群失败', + data: { code: error?.code ?? null }, + }); + } +}); + module.exports = router; diff --git a/backend/src/index.js b/backend/src/index.js index b7c67f9..6f4c4e6 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -68,6 +68,9 @@ const { const { startMkhReplenishAlertScheduler, } = require('./jobs/mkhReplenishAlertJob'); +const { + startWarehouseLogisticsExceptionNotifyScheduler, +} = require('./jobs/warehouseLogisticsExceptionNotifyJob'); const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); const { ensureLocalSchema } = require('./services/localSchema'); const { @@ -791,6 +794,7 @@ async function startServer(options = {}) { startGoodsFirstSeenScheduler(); startAlibaba1688ImageSearchExpiryScheduler(); startMkhReplenishAlertScheduler(); + startWarehouseLogisticsExceptionNotifyScheduler(); startDailyInventorySnapshotScheduler(); // Keep the first selection-pool page request from paying the remote schema diff --git a/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js new file mode 100644 index 0000000..0663c70 --- /dev/null +++ b/backend/src/jobs/warehouseLogisticsExceptionNotifyJob.js @@ -0,0 +1,433 @@ +'use strict'; + +/** + * 仓储中心物流转运异常 · 每天定点把异常明细以**文本消息**推到钉钉群 + * ──────────────────────────────────────────────────────────────────────────── + * 业务口径(2026-10-22 定稿): + * ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群; + * **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。 + * ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。 + * 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。 + * N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。 + * ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置, + * 关键词一般是「异常」,缺了会被拒(errcode 310000)。 + * ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重, + * 服务重启不会重复轰炸;手动「立即推送」才强制重发。 + * + * 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。 + */ + +const cron = require('node-cron'); +const { getLogisticsExceptionExportRows } = require('../services/warehouseLogisticsExceptionService'); +const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender'); +const { + DEFAULT_MAX_DETAIL_ROWS, + DEFAULT_PAGE_URL, + getWarehouseLogisticsExceptionNotifyRuntimeConfig, + isWarehouseLogisticsExceptionNotifyReady, + loadWarehouseLogisticsExceptionNotifyState, + saveWarehouseLogisticsExceptionNotifyState, +} = require('../services/warehouseLogisticsExceptionNotifySettings'); + +const TIMEZONE = 'Asia/Shanghai'; +// 每分钟醒一次,比对「现在是不是配置的推送时刻」—— 这样业务在页面上改时间不用重启后端 +const DEFAULT_TICK_CRON = '* * * * *'; +// 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败 +const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300; + +let tickRunning = false; + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +/** 北京时间的 YYYY-MM-DD */ +function chinaDateKey(date = new Date()) { + const base = date instanceof Date ? date : new Date(date); + if (!Number.isFinite(base.getTime())) return ''; + return new Intl.DateTimeFormat('en-CA', { + timeZone: TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(base); +} + +/** 北京时间的 HH:mm —— 用来跟配置里的 sendTime 比对 */ +function chinaHourMinute(date = new Date()) { + const base = date instanceof Date ? date : new Date(date); + if (!Number.isFinite(base.getTime())) return ''; + return new Intl.DateTimeFormat('en-GB', { + timeZone: TIMEZONE, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(base); +} + +/** + * 字段清洗:换行/连续空白压成单空格。 + * ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。 + */ +function cleanCell(value, fallback = '') { + const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim(); + return text || fallback; +} + +/** '2026-10-21 16:00:00' → '10-21 16:00';空值给「—」 */ +function shortTime(value) { + const text = cleanCell(value); + if (!text) return '—'; + const match = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/); + if (!match) return text.slice(0, 16); + return `${match[2]}-${match[3]} ${match[4]}:${match[5]}`; +} + +function formatHours(value) { + const num = Number(value); + if (!Number.isFinite(num)) return '—'; + return `${Math.round(num * 10) / 10}h`; +} + +/** 明细一行:仓|内部单号|快递|异常类型|发货时间|待处理 */ +function buildDetailLine(row, index) { + const parts = [ + cleanCell(row?.warehouseName, '未知仓'), + cleanCell(row?.oId, '无单号'), + cleanCell(row?.carrier, '未知快递'), + cleanCell(row?.exceptionLabel, '异常'), + shortTime(row?.sendTime), + formatHours(row?.pendingHours), + ]; + return `${index}. ${parts.join('|')}`; +} + +/** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/ +function buildCenterLines(data = {}) { + const rows = Array.isArray(data.rows) ? data.rows : []; + const packagesByCenter = rows.reduce((acc, row) => { + const key = String(row?.centerKey || ''); + if (!key) return acc; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + return (data.centers || []) + .map((center) => ({ + name: cleanCell(center.name || center.key, '未知仓'), + exceptionPackages: packagesByCenter[center.key] || 0, + shippedOrders: Number(center.shippedOrders) || 0, + })) + .filter((item) => item.exceptionPackages > 0) + .sort((a, b) => b.exceptionPackages - a.exceptionPackages) + .slice(0, 12) + .map((item, index) => `${index + 1}. ${item.name} ${item.exceptionPackages} 个(发货 ${item.shippedOrders} 单)`); +} + +function buildTypeLine(data = {}) { + const summary = data.summary || {}; + const entries = Object.entries(summary.byType || {}); + if (!entries.length) return ''; + return entries + .map(([type, count]) => { + const meta = (data.types || []).find((item) => item.key === type); + return { label: meta?.label || type, count: Number(count) || 0 }; + }) + .sort((a, b) => b.count - a.count) + .map((item) => `${item.label} ${item.count}`) + .join(' · '); +} + +function buildPageLink(pageUrl, verifyDate) { + const base = String(pageUrl || DEFAULT_PAGE_URL); + if (!verifyDate) return base; + const glue = base.includes('?') ? '&' : '?'; + return `${base}${glue}date=${encodeURIComponent(verifyDate)}`; +} + +/** + * 组装群消息正文(纯函数,便于单测)。 + * 结构:抬头 → 数字 → 分类型 → 按仓 → 明细前 N 条 → 页面链接 + * 超长处理:从明细末尾往前砍,砍到能发为止(绝不让钉钉 400 把整条消息吞掉)。 + * + * @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}} + */ +function buildNotifyText(data = {}, options = {}) { + const batch = data.batch || {}; + const summary = data.summary || {}; + const rows = Array.isArray(data.rows) ? data.rows : []; + const exclusions = data.exclusions || {}; + + const maxDetailRows = Number.isFinite(Number(options.maxDetailRows)) + ? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows)))) + : DEFAULT_MAX_DETAIL_ROWS; + const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, batch.verifyDate); + + const totalPackages = Number(summary.exceptionPackages) || rows.length; + const shipped = Number(summary.shippedOrders) || 0; + const ratio = Number.isFinite(Number(summary.exceptionRate)) ? `${Number(summary.exceptionRate)}%` : '—'; + + const head = [ + `【仓储物流转运异常】${cleanCell(batch.label, '本批核验')}`, + `已发货 ${shipped} 单 / ${Number(summary.packageCount) || 0} 个包裹,异常 ${totalPackages} 个包裹(${ratio})`, + ]; + + const typeLine = buildTypeLine(data); + if (typeLine) head.push(`类型:${typeLine}`); + + if (totalPackages === 0) { + head.push('本批无转运异常,全部正常。'); + } + + const excludedCount = (Number(exclusions.carrierPackages) || 0) + + (Number(exclusions.voidedPackages) || 0) + + (Number(exclusions.nonRealPackages) || 0); + if (excludedCount > 0) { + head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`); + } + + const centerLines = totalPackages > 0 ? buildCenterLines(data) : []; + const detailAll = rows.map((row, index) => buildDetailLine(row, index + 1)); + const wantCount = Math.min(maxDetailRows, detailAll.length); + + // 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出) + const buildTail = (listed) => { + const lines = []; + if (totalPackages > 0) { + if (listed === 0) { + lines.push(''); + lines.push(`明细共 ${totalPackages} 条,消息里不放,请点下面链接查看。`); + } else if (totalPackages > listed) { + lines.push(`…其余 ${totalPackages - listed} 条见页面。`); + } + lines.push(`查看全部 / 导出 Excel:${pageUrl}`); + } + return lines; + }; + + const assemble = (listed, withCenters, extraNote = '') => { + const blocks = [...head]; + if (withCenters && centerLines.length) { + blocks.push(''); + blocks.push('按仓(异常包裹):'); + blocks.push(...centerLines); + } + if (listed > 0) { + blocks.push(''); + blocks.push(`明细前 ${listed} 条:`); + blocks.push(...detailAll.slice(0, listed)); + } + const tail = buildTail(listed); + if (tail.length) blocks.push(...tail); + if (extraNote) blocks.push(extraNote); + const text = blocks.join('\n'); + return { text, bytes: Buffer.byteLength(text, 'utf8') }; + }; + + // 先按期望条数拼;超长就从明细末尾往前砍(先砍明细,再砍分仓块) + // ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」—— + // 后者是正常行为(listedCount < rowCount),混在一起会让页面误报「超长精简」。 + let listed = wantCount; + let withCenters = centerLines.length > 0; + let result = assemble(listed, withCenters); + let truncated = false; + + while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) { + listed -= 1; + truncated = true; + result = assemble(listed, withCenters); + } + if (result.bytes > SAFE_CONTENT_BYTES && withCenters) { + withCenters = false; + result = assemble(listed, withCenters); + } + if (result.bytes > SAFE_CONTENT_BYTES) { + // 极端情况(仓名/链接特别长):硬截断,至少保证消息能发出去 + const chars = Array.from(result.text); + let bytes = 0; + let cut = ''; + for (const char of chars) { + const size = Buffer.byteLength(char, 'utf8'); + if (bytes + size > SAFE_CONTENT_BYTES) break; + bytes += size; + cut += char; + } + result = { text: cut, bytes }; + truncated = true; + } + + return { + text: result.text, + rowCount: totalPackages, + listedCount: listed, + bytes: result.bytes, + truncated, + }; +} + +/** + * 执行一次推送。参数: + * now 指定时间(测试用) + * date 指定核验日 'YYYY-MM-DD' + * force 忽略「今天已经推过」的限制(页面「立即推送」用) + * dryRun 只组装消息不发送(预览 / 单测用) + * probe 只发一条通道测试文本(不查库、不依赖业务数据) + */ +async function pushWarehouseLogisticsExceptionNotify(options = {}) { + const now = options.now === undefined ? new Date() : new Date(options.now); + const pushDate = chinaDateKey(now); + const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true }); + + // 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅) + if (options.probe === true) { + if (!isWarehouseLogisticsExceptionNotifyReady(config)) { + return { sent: false, reason: 'not_configured', missingFields: config.missingFields }; + } + const delivery = await sendTextToWebhook({ + webhookUrl: config.webhookUrl, + secret: config.webhookSecret, + content: '【仓储物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。', + }); + return { sent: true, probe: true, delivery }; + } + + const state = await loadWarehouseLogisticsExceptionNotifyState(); + if (state?.lastSentDate === pushDate && options.force !== true) { + return { sent: false, reason: 'already_sent_today', pushDate, lastSentAt: state.lastSentAt || null }; + } + if (!config.enabled && options.force !== true) { + return { sent: false, reason: 'disabled' }; + } + + const dryRun = options.dryRun === true; + if (!dryRun && !isWarehouseLogisticsExceptionNotifyReady(config)) { + return { sent: false, reason: 'not_configured', missingFields: config.missingFields }; + } + + const data = await getLogisticsExceptionExportRows({ date: options.date }); + if (data?.notStarted) { + // 还没到核验时刻(例如把推送时间设在 09:00 之前)→ 不推空数据,等下一轮或人工 + return { sent: false, reason: 'not_started', batch: data.batch || null }; + } + + const built = buildNotifyText(data, { + maxDetailRows: config.maxDetailRows, + pageUrl: config.pageUrl, + }); + + if (dryRun) { + return { + sent: false, + reason: 'dry_run', + text: built.text, + rowCount: built.rowCount, + listedCount: built.listedCount, + bytes: built.bytes, + truncated: built.truncated, + batch: data.batch || null, + summary: data.summary || null, + }; + } + + const delivery = await sendTextToWebhook({ + webhookUrl: config.webhookUrl, + secret: config.webhookSecret, + content: built.text, + atAll: config.atAll === true, + }); + + await saveWarehouseLogisticsExceptionNotifyState({ + lastSentDate: pushDate, + lastSentAt: new Date().toISOString(), + lastStatus: 'sent', + lastError: '', + rowCount: built.rowCount, + listedCount: built.listedCount, + bytes: built.bytes, + truncated: built.truncated, + verifyDate: data.batch?.verifyDate || '', + }); + + return { + sent: true, + rowCount: built.rowCount, + listedCount: built.listedCount, + bytes: built.bytes, + truncated: built.truncated, + text: built.text, + batch: data.batch || null, + summary: data.summary || null, + delivery, + }; +} + +async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', options = {}) { + try { + const result = await pushWarehouseLogisticsExceptionNotify(options); + if (result.sent) { + console.log(`[WleNotify] ${source} 已推送(${result.rowCount} 条异常,列出 ${result.listedCount} 条,${result.bytes} 字节)`); + } else { + console.log(`[WleNotify] ${source} 跳过: ${result.reason}`); + } + return result; + } catch (error) { + console.error(`[WleNotify] ${source} 执行失败:`, error.message || error); + try { + await saveWarehouseLogisticsExceptionNotifyState({ + ...(await loadWarehouseLogisticsExceptionNotifyState()), + lastError: String(error.message || error).slice(0, 500), + lastFailedAt: new Date().toISOString(), + }); + } catch (_ignored) { /* 状态写失败不影响主流程 */ } + return { sent: false, reason: 'error', error: error.message || String(error) }; + } +} + +/** + * 调度:每分钟比对「北京时间 HH:mm == 配置的 sendTime」。 + * 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。 + */ +function startWarehouseLogisticsExceptionNotifyScheduler() { + const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true'; + if (disabled) { + console.log('[Scheduler] 物流转运异常钉钉推送已禁用'); + return; + } + const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim(); + cron.schedule(expression, () => { + if (tickRunning) return; + tickRunning = true; + (async () => { + try { + const now = new Date(); + const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig(); + if (!config.enabled) return; + if (chinaHourMinute(now) !== config.sendTime) return; + await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now }); + } catch (error) { + console.error('[WleNotify] 定时检查失败:', error.message || error); + } finally { + tickRunning = false; + } + })(); + }, { timezone: TIMEZONE }); + + console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`); +} + +module.exports = { + DEFAULT_TICK_CRON, + SAFE_CONTENT_BYTES, + TIMEZONE, + buildCenterLines, + buildDetailLine, + buildNotifyText, + buildTypeLine, + chinaDateKey, + chinaHourMinute, + cleanCell, + pushWarehouseLogisticsExceptionNotify, + runWarehouseLogisticsExceptionNotifyJob, + shortTime, + startWarehouseLogisticsExceptionNotifyScheduler, +}; diff --git a/backend/src/services/dingtalkRobotWebhookSender.js b/backend/src/services/dingtalkRobotWebhookSender.js new file mode 100644 index 0000000..c88d687 --- /dev/null +++ b/backend/src/services/dingtalkRobotWebhookSender.js @@ -0,0 +1,210 @@ +'use strict'; + +/** + * 钉钉「群自定义机器人」webhook 发文本通道(2026-10-22 新增) + * ──────────────────────────────────────────────────────────────────────────── + * 为什么不用企业内部应用发文件? + * 群机器人 webhook **只能发** text / markdown / link / actionCard / feedCard, + * 发 .xlsx 附件必须走企业内部应用(AppKey/AppSecret/robotCode/openConversationId)。 + * 业务最终选择「群里直接看文本」,于是回到最简单的一条路: + * 只要一个 webhook 地址(加签可选),不用申请应用、不用管 token 过期。 + * + * 请求形状(钉钉官方「自定义机器人接入」): + * POST https://oapi.dingtalk.com/robot/send?access_token=xxx×tamp=xxx&sign=xxx + * { "msgtype": "text", "text": { "content": "..." }, "at": { "isAtAll": false } } + * → { "errcode": 0, "errmsg": "ok" } + * + * 三个必须知道的限制(踩过就知道疼): + * ① text.content **上限 20000 字节**(中文 UTF-8 是 3 字节/字)→ 超了钉钉直接 400, + * 所以这里按**字节**截断(不是按字符),并返回是否被截断。 + * ② 加了「自定义关键词」安全设置时,content 里必须含关键词(常用「异常」「提醒」), + * 否则 errcode 310000。这个错要让业务自己看得懂,所以原样透出 errmsg。 + * ③ 加签密钥(SEC 开头)算出来的 sign 必须 **urlEncode**,且 timestamp 是**毫秒**。 + * 顺序错了就是 310000 sign not match,很难看出是编码问题。 + * + * 失败一律抛错并带上钉钉原始 errcode/errmsg —— 业务排错全靠这个码。 + */ + +const crypto = require('crypto'); + +const OAPI_BASE = process.env.DINGTALK_OAPI_BASE || 'https://oapi.dingtalk.com'; +const REQUEST_TIMEOUT_MS = Math.max(Number(process.env.DINGTALK_WEBHOOK_TIMEOUT_MS) || 20000, 3000); +// 钉钉 text 消息 content 的硬上限(字节)。留 200 字节余量给自己追加的尾巴 +const MAX_CONTENT_BYTES = 20000; +const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 200; + +function normalizeText(value, maxLength = 512) { + return String(value ?? '').trim().slice(0, maxLength); +} + +/** 按 UTF-8 **字节**截断,且不把多字节字符切一半 */ +function truncateBytes(text, maxBytes = SAFE_CONTENT_BYTES) { + const source = String(text ?? ''); + if (Buffer.byteLength(source, 'utf8') <= maxBytes) return { text: source, truncated: false }; + const chars = Array.from(source); + let bytes = 0; + let out = ''; + for (const char of chars) { + const size = Buffer.byteLength(char, 'utf8'); + if (bytes + size > maxBytes) break; + bytes += size; + out += char; + } + return { text: out, truncated: true }; +} + +function buildDingTalkError(label, payload, httpStatus) { + const code = payload?.errcode ?? payload?.code ?? httpStatus ?? null; + const message = payload?.errmsg || payload?.message || `${label}失败`; + const error = new Error(`${label}失败(${code}):${message}`); + error.code = code; + error.httpStatus = httpStatus || null; + error.dingtalk = payload || null; + return error; +} + +/** + * 校验 webhook 地址。放宽 host(支持自建代理 / 测试 stub),但必须 https 且是 robot/send。 + * 返回去掉首尾空白后的地址;不合法抛 400(页面上直接提示,不让它跑到定时任务里才炸)。 + */ +function normalizeWebhookUrl(value) { + const text = normalizeText(value, 1024); + if (!text) { + throw Object.assign(new Error('请填写钉钉群机器人的 Webhook 地址'), { status: 400, code: 'WEBHOOK_REQUIRED' }); + } + let url; + try { + url = new URL(text); + } catch (_error) { + throw Object.assign(new Error('Webhook 地址不是合法 URL'), { status: 400, code: 'WEBHOOK_INVALID' }); + } + if (!/^https?:$/.test(url.protocol)) { + throw Object.assign(new Error('Webhook 地址必须以 http(s):// 开头'), { status: 400, code: 'WEBHOOK_INVALID' }); + } + // 严格匹配 `/robot/send`(不要用「以 /robot/send 结尾」—— 那样 + // `topapi/robot/send` 这种企业内部应用地址也会被放过去,最后报一个难懂的 404) + if (!/^\/robot\/send\/?$/.test(url.pathname)) { + throw Object.assign( + new Error('这不像群机器人 Webhook(正常形如 https://oapi.dingtalk.com/robot/send?access_token=xxx)'), + { status: 400, code: 'WEBHOOK_INVALID' } + ); + } + if (!url.searchParams.get('access_token')) { + throw Object.assign( + new Error('Webhook 地址缺少 access_token 参数,请从钉钉群机器人设置里完整复制'), + { status: 400, code: 'WEBHOOK_INVALID' } + ); + } + return url.toString(); +} + +/** + * 加签:sign = urlEncode(base64(HmacSHA256(secret, `${timestamp}\n${secret}`))) + * ⚠️ HMAC 的 key 是 secret、消息是 `timestamp\nsecret`(顺序反过来就是 310000) + */ +function buildSignedUrl(webhookUrl, secret, timestampMs = Date.now()) { + const timestamp = String(Number(timestampMs) || Date.now()); + const timestamped = new URL(webhookUrl); + timestamped.searchParams.set('timestamp', timestamp); + + const text = normalizeText(secret, 256); + if (!text) return timestamped.toString(); + + const sign = crypto + .createHmac('sha256', text) + .update(`${timestamp}\n${text}`, 'utf8') + .digest('base64'); + timestamped.searchParams.set('sign', sign); + return timestamped.toString(); +} + +async function requestJson(label, url, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + const raw = await response.text(); + let payload = null; + try { + payload = raw ? JSON.parse(raw) : {}; + } catch (_error) { + payload = { errmsg: raw.slice(0, 500) }; + } + if (!response.ok) throw buildDingTalkError(label, payload, response.status); + if (payload && payload.errcode !== undefined && Number(payload.errcode) !== 0) { + throw buildDingTalkError(label, payload, response.status); + } + return payload || {}; + } catch (error) { + if (error.name === 'AbortError') { + throw Object.assign(new Error(`${label}超时(${REQUEST_TIMEOUT_MS}ms)`), { code: 'TIMEOUT' }); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +/** + * 发一条文本消息到群。 + * @param {object} params + * @param {string} params.webhookUrl 群机器人 Webhook(含 access_token) + * @param {string} [params.secret] 加签密钥(安全设置选「加签」时填;选关键词/IP 时留空) + * @param {string} params.content 纯文本(支持 \n 换行) + * @param {boolean} [params.atAll] 是否 @所有人 + * @param {string[]} [params.atMobiles] + * @returns {Promise<{delivered:boolean,truncated:boolean,bytes:number,content:string}>} + */ +async function sendTextToWebhook({ + webhookUrl, secret, content, atAll = false, atMobiles = [], +}) { + const base = normalizeWebhookUrl(webhookUrl); + const raw = String(content ?? ''); + if (!raw.trim()) { + throw Object.assign(new Error('待发送的消息内容为空'), { code: 'EMPTY_CONTENT' }); + } + + const limited = truncateBytes(raw); + const url = buildSignedUrl(base, secret); + const mobiles = (Array.isArray(atMobiles) ? atMobiles : []) + .map((item) => normalizeText(item, 32)) + .filter((item) => /^\d{6,20}$/.test(item)); + + const payload = await requestJson('发送钉钉群消息', url, { + method: 'POST', + headers: { 'Content-Type': 'application/json;charset=utf-8' }, + body: JSON.stringify({ + msgtype: 'text', + text: { content: limited.text }, + at: { isAtAll: atAll === true, atMobiles: mobiles }, + }), + }); + + return { + delivered: true, + truncated: limited.truncated, + bytes: Buffer.byteLength(limited.text, 'utf8'), + content: limited.text, + errmsg: normalizeText(payload.errmsg, 200) || 'ok', + }; +} + +/** + * 「测试推送」用一句短文本探活:只验 webhook(+加签)能不能通。 + * 不读取业务数据 —— 测试时不该因为数据慢而误判成通道坏。 + */ +async function sendProbeText({ webhookUrl, secret, text }) { + const content = normalizeText(text, 500) + || '【仓储中心物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。'; + return sendTextToWebhook({ webhookUrl, secret, content }); +} + +module.exports = { + MAX_CONTENT_BYTES, + OAPI_BASE, + buildSignedUrl, + normalizeWebhookUrl, + sendProbeText, + sendTextToWebhook, + truncateBytes, +}; diff --git a/backend/src/services/warehouseLogisticsExceptionNotifySettings.js b/backend/src/services/warehouseLogisticsExceptionNotifySettings.js new file mode 100644 index 0000000..2b9fbce --- /dev/null +++ b/backend/src/services/warehouseLogisticsExceptionNotifySettings.js @@ -0,0 +1,318 @@ +'use strict'; + +/** + * 「仓储中心物流转运异常」每天定点推文本到钉钉群 —— 配置与状态存取 + * ──────────────────────────────────────────────────────────────────────────── + * 配置分两层存(与统一通知中心同一套做法,见 yydNotificationCenterSettings): + * · 普通字段 → 本地库 `system_config`,key = warehouse_logistics_exception_notify_config + * · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件) + * + * 通道是「钉钉群自定义机器人 webhook」(2026-10-22 业务定:不发 .xlsx 附件, + * 群里直接看文本),所以只有两个凭据字段: + * · webhookUrl 群机器人地址(含 access_token;完整回显,业务要能核对) + * · webhookSecret 加签密钥(SEC 开头,只回掩码;安全设置选「关键词/IP」时留空) + * + * 推送时刻用页面上的 HH:mm(默认 09:10,核验 09:00 之后),**按北京时间解释**: + * 生产容器是 UTC,绝不能拿容器本地时间比。 + */ + +const database = require('../config/database'); +const { + getResilientSecret, + saveResilientSecret, +} = require('./resilientSecretStore'); + +const CONFIG_KEY = 'warehouse_logistics_exception_notify_config'; +const SECRET_KEY = 'warehouse_logistics_exception_notify_webhook_secret'; +const STATE_KEY = 'warehouse_logistics_exception_notify_state'; +const DEFAULT_SEND_TIME = '09:10'; +// 明细条数:text 消息上限 20000 字节,30 条中文明细约 9~12KB,留足余量 +const DEFAULT_MAX_DETAIL_ROWS = 30; +const MAX_DETAIL_ROWS_LIMIT = 50; +// 页面地址(消息里给「查看全部」链接)。生产是带 8443 的域名,别写成裸域名 +const DEFAULT_PAGE_URL = process.env.WLE_PAGE_URL + || 'https://ck.yydd.ai:8443/planning/operation/warehouse-logistics-exception'; +const CACHE_TTL_MS = 30 * 1000; + +let cachedConfig = null; +let cachedAt = 0; + +function normalizeText(value, maxLength = 512) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function safeJsonParse(value, fallback = {}) { + if (value && typeof value === 'object') return value; + try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; } +} + +function maskSecret(value) { + const text = normalizeText(value); + if (!text) return ''; + if (text.length <= 8) return `${text.slice(0, 2)}******`; + return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`; +} + +/** HH:mm(00:00 ~ 23:59);非法输入退回默认 09:10,不抛错(避免把定时任务配挂) */ +function normalizeSendTime(value) { + const text = normalizeText(value, 5); + if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text; + return DEFAULT_SEND_TIME; +} + +/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */ +function normalizeMaxDetailRows(value) { + const num = Number(value); + if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS; + return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num))); +} + +function normalizePageUrl(value) { + const text = normalizeText(value, 1024); + if (!text) return DEFAULT_PAGE_URL; + try { + const url = new URL(text); + if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL; + return url.toString(); + } catch (_error) { + return DEFAULT_PAGE_URL; + } +} + +function normalizeBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const text = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(text)) return true; + if (['0', 'false', 'no', 'off'].includes(text)) return false; + return fallback; +} + +function splitSendTime(sendTime) { + const [hour, minute] = normalizeSendTime(sendTime).split(':').map((part) => Number(part)); + return { hour, minute }; +} + +async function upsertConfigRow(value) { + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + const text = JSON.stringify(value); + if (rows[0]) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [text, 'json', CONFIG_KEY] + ); + } else { + await database.local.insert('system_config', { + config_key: CONFIG_KEY, + config_value: text, + config_type: 'json', + }); + } +} + +async function readConfigRow() { + try { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [CONFIG_KEY] + ); + return safeJsonParse(rows[0]?.configValue, {}); + } catch (error) { + console.warn('[WleNotify] 读取推送配置失败:', error.message); + return {}; + } +} + +/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */ +async function getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh = false } = {}) { + if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig }; + + const stored = await readConfigRow(); + const secret = await getResilientSecret(SECRET_KEY, { + legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)], + fallbackValues: [normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)], + }); + + cachedConfig = { + enabled: normalizeBoolean(stored.enabled, false), + webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024) + || normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK, 1024), + webhookSecret: secret.value, + webhookSecretSource: secret.source, + sendTime: normalizeSendTime(stored.sendTime || stored.send_time), + maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows), + atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false), + pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url), + source: Object.keys(stored).length > 0 ? 'database' : 'default', + }; + cachedAt = Date.now(); + return { ...cachedConfig }; +} + +/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/ +function missingFields(config = {}) { + const missing = []; + if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址'); + return missing; +} + +function isWarehouseLogisticsExceptionNotifyReady(config = {}) { + return missingFields(config).length === 0; +} + +function toAdminConfig(config = {}) { + return { + enabled: config.enabled === true, + webhookUrl: config.webhookUrl || '', + webhookSecretConfigured: Boolean(config.webhookSecret), + webhookSecretPreview: maskSecret(config.webhookSecret), + webhookSecretSource: config.webhookSecretSource || 'unconfigured', + sendTime: normalizeSendTime(config.sendTime), + maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows), + maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT, + atAll: config.atAll === true, + pageUrl: normalizePageUrl(config.pageUrl), + ready: isWarehouseLogisticsExceptionNotifyReady(config), + missingFields: missingFields(config), + source: config.source || 'default', + }; +} + +async function getWarehouseLogisticsExceptionNotifyAdminConfig() { + const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true }); + return toAdminConfig(config); +} + +/** + * 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填); + * 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。 + * 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。 + */ +async function saveWarehouseLogisticsExceptionNotifyConfig(input = {}) { + const current = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true }); + const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512); + const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true; + + const config = { + enabled: normalizeBoolean(input.enabled, current.enabled), + webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl, + webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret), + sendTime: input.sendTime === undefined && input.send_time === undefined + ? normalizeSendTime(current.sendTime) + : normalizeSendTime(input.sendTime ?? input.send_time), + maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined + ? normalizeMaxDetailRows(current.maxDetailRows) + : normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows), + atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true), + pageUrl: input.pageUrl === undefined && input.page_url === undefined + ? normalizePageUrl(current.pageUrl) + : normalizePageUrl(input.pageUrl ?? input.page_url), + }; + + if (config.enabled) { + const missing = missingFields(config); + if (missing.length) { + throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 }); + } + } + + if (config.webhookUrl) { + // 这里只做「像不像」的浅校验;真正的连通性由「测试推送」按钮验证 + const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender'); + try { + config.webhookUrl = normalizeWebhookUrl(config.webhookUrl); + } catch (error) { + throw Object.assign(new Error(error.message), { status: 400 }); + } + } + + if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret); + await upsertConfigRow({ + enabled: config.enabled, + webhookUrl: config.webhookUrl, + sendTime: config.sendTime, + maxDetailRows: config.maxDetailRows, + atAll: config.atAll, + pageUrl: config.pageUrl, + updatedAt: new Date().toISOString(), + }); + + cachedConfig = { + ...config, + webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')), + source: 'database', + }; + cachedAt = Date.now(); + return toAdminConfig(cachedConfig); +} + +/** 上次推送结果(页面展示 + 定时任务用来判断「今天是不是已经推过」) */ +async function loadWarehouseLogisticsExceptionNotifyState() { + try { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [STATE_KEY] + ); + const state = safeJsonParse(rows[0]?.configValue, {}); + return state && typeof state === 'object' ? state : {}; + } catch (error) { + console.warn('[WleNotify] 读取推送状态失败:', error.message); + return {}; + } +} + +async function saveWarehouseLogisticsExceptionNotifyState(next = {}) { + const value = JSON.stringify(next && typeof next === 'object' ? next : {}); + try { + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [STATE_KEY] + ); + if (rows[0]) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [value, 'json', STATE_KEY] + ); + } else { + await database.local.insert('system_config', { + config_key: STATE_KEY, + config_value: value, + config_type: 'json', + }); + } + } catch (error) { + console.warn('[WleNotify] 保存推送状态失败:', error.message); + } +} + +function clearNotifyConfigCache() { + cachedConfig = null; + cachedAt = 0; +} + +module.exports = { + CONFIG_KEY, + DEFAULT_MAX_DETAIL_ROWS, + DEFAULT_PAGE_URL, + DEFAULT_SEND_TIME, + MAX_DETAIL_ROWS_LIMIT, + SECRET_KEY, + STATE_KEY, + clearNotifyConfigCache, + getWarehouseLogisticsExceptionNotifyAdminConfig, + getWarehouseLogisticsExceptionNotifyRuntimeConfig, + isWarehouseLogisticsExceptionNotifyReady, + loadWarehouseLogisticsExceptionNotifyState, + maskSecret, + missingFields, + normalizeMaxDetailRows, + normalizePageUrl, + normalizeSendTime, + saveWarehouseLogisticsExceptionNotifyConfig, + saveWarehouseLogisticsExceptionNotifyState, + splitSendTime, + toAdminConfig, +}; diff --git a/backend/test/warehouseLogisticsExceptionNotify.test.js b/backend/test/warehouseLogisticsExceptionNotify.test.js new file mode 100644 index 0000000..1864c31 --- /dev/null +++ b/backend/test/warehouseLogisticsExceptionNotify.test.js @@ -0,0 +1,394 @@ +'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('明细行:仓|内部单号|快递|异常类型|发货时间|待处理,字段里换行要压平', () => { + const line = job.buildDetailLine({ + warehouseName: '崔桥工厂仓', + oId: '9845001', + carrier: '京东快递', + exceptionLabel: '已揽收未中转', + sendTime: '2026-10-21 16:00:00', + pendingHours: 17.25, + lastLogisticInfo: '第一行\n第二行', + }, 3); + + assert.equal(line, '3. 崔桥工厂仓|9845001|京东快递|已揽收未中转|10-21 16:00|17.3h'); + + // 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常) + assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁'); + // 空值兜底:不能让消息里出现「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(); + } +}); diff --git a/frontend/src/api/warehouseLogisticsException.js b/frontend/src/api/warehouseLogisticsException.js index 0204a65..1bdab57 100644 --- a/frontend/src/api/warehouseLogisticsException.js +++ b/frontend/src/api/warehouseLogisticsException.js @@ -32,3 +32,28 @@ export const getWarehouseLogisticsExceptionExportRows = (params = {}) => request headers: { 'Cache-Control': 'no-cache' }, } ); + +// ── 每天定点推文本到钉钉群 ──────────────────────────────────────────────── +// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选) +// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview) +export const getWarehouseLogisticsExceptionNotifySettings = () => request.get( + '/api/warehouse-logistics-exception/notify/settings', + { timeout: 30000, headers: { 'Cache-Control': 'no-cache' } } +); + +// 保存配置:加签密钥留空 = 沿用已存的,不必每次重填 +export const saveWarehouseLogisticsExceptionNotifySettings = (payload = {}) => request.put( + '/api/warehouse-logistics-exception/notify/settings', + payload, + { timeout: 30000 } +); + +// 立即推送 / 预览正文 / 测试通道 +// dryRun=true 只组装消息不发送(返回正文全文) +// probe=true 只发一句通道测试,不查业务数据 +// 冷启动要全量扫一遍物流宽表(生产实测 3~4 分钟),超时给足 +export const runWarehouseLogisticsExceptionNotify = (payload = {}) => request.post( + '/api/warehouse-logistics-exception/notify/run', + payload, + { timeout: 10 * 60 * 1000 } +); diff --git a/frontend/src/pages/WarehouseLogisticsException/index.jsx b/frontend/src/pages/WarehouseLogisticsException/index.jsx index 92202e3..65e0726 100644 --- a/frontend/src/pages/WarehouseLogisticsException/index.jsx +++ b/frontend/src/pages/WarehouseLogisticsException/index.jsx @@ -1,13 +1,17 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - Alert, Button, Card, DatePicker, Dropdown, Empty, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message, + Alert, Button, Card, Collapse, DatePicker, Dropdown, Empty, Input, InputNumber, Modal, Space, Spin, Statistic, + Switch, Table, Tag, TimePicker, Tooltip, Typography, message, } from 'antd'; -import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons'; +import { BellOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { SycmPage } from '../../components/Common'; import { getWarehouseLogisticsExceptions, getWarehouseLogisticsExceptionExportRows, + getWarehouseLogisticsExceptionNotifySettings, + saveWarehouseLogisticsExceptionNotifySettings, + runWarehouseLogisticsExceptionNotify, } from '../../api/warehouseLogisticsException'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -78,6 +82,70 @@ const EXPORT_MENU_ITEMS = [ { key: 'csv', label: '导出 CSV(.csv)' }, ]; +// 「异常通知」表单初始值。加签密钥永远不回显明文:留空 = 沿用服务端已存的那份 +const EMPTY_NOTIFY_FORM = { + enabled: false, + sendTime: '09:10', + webhookUrl: '', + webhookSecret: '', + maxDetailRows: 30, + atAll: false, + pageUrl: '', +}; + +// 后端「没推」的原因 → 人话(后端 reason 是给日志用的英文标识) +const NOTIFY_REASON_TEXT = { + disabled: '推送开关没打开', + not_configured: 'Webhook 地址还没填', + already_sent_today: '今天已经推过了(一天只推一次)', + not_started: '这一批还没到核验时刻,没有结论可推', + dry_run: '仅预览,未发送', +}; + +// 钉钉群机器人 webhook 怎么拿 —— 业务自己配,写清楚省得来问 +const NOTIFY_HELP_ITEMS = [ + { + key: 'webhook', + label: '① 拿群机器人 Webhook 地址', + children: ( +
https://oapi.dingtalk.com/robot/send?access_token=xxxx,
+ 整串(含 access_token)直接贴到下面。
+ SEC 开头的密钥填到「加签密钥」里。
+ {notifyPreview.text || '(消息正文为空)'}
+