在「仓储中心物流转运异常」页新增「异常通知」:每天 09:10(可改)把当天核验批次
的异常情况以**文本消息**推到钉钉群,0 条也推(用来确认任务没挂)。
为什么是 text 而不是 Excel 附件:钉钉群自定义机器人 webhook 只支持
text/markdown/link/actionCard,发文件必须走企业内部应用(AppKey/AppSecret/
robotCode/openConversationId 四项凭据 + 应用权限 + 机器人进群)。业务确认
「群里看文本就够,要明细点链接去页面导出」,于是走最轻的 webhook。
后端
- 新增 services/dingtalkRobotWebhookSender.js:POST /robot/send + msgtype=text
· 加签 urlEncode(base64(HmacSHA256(secret, "timestamp\nsecret"))),timestamp 毫秒
· text.content 按 UTF-8 **字节**截断到 20000(钉钉硬上限),不切半个汉字
· webhook 严格匹配 ^/robot/send$ 且必须有 access_token;errcode/errmsg 原样抛
- 新增 services/warehouseLogisticsExceptionNotifySettings.js:
· webhookUrl 存在 system_config(完整回显,业务要核对);加签密钥走
resilientSecretStore,接口只回 configured + 掩码,留空=沿用已存
· sendTime 非法退回 09:10;maxDetailRows 0~50 默认 30
· 状态 lastSentDate(按推送日去重)等存 system_config
- 新增 jobs/warehouseLogisticsExceptionNotifyJob.js:
· buildNotifyText:抬头【仓储物流转运异常】→ 发货/包裹/异常/异常率 → 分类型
→ 剔除数 → 按仓(只列有异常的仓)→ 明细前 N 条 → 页面链接(带 date)
· 明细行「仓|内部单号|快递|异常类型|发货时间|待处理」,字段内换行压平
· 超长从明细末尾逐条砍并标记 truncated(truncated 只表示被字节上限砍过,
不表示「按配置只列前 N 条」)
· 每分钟 tick 比对北京时间 HH:mm == 配置 sendTime,改时间不用重启
- api:GET/PUT /notify/settings、GET /notify/state、POST /notify/run
(dryRun 预览不校验凭据;probe 只发一句测试文本、不查业务数据)
前端
- 「导出」右侧新增「异常通知」(铃铛)弹窗:开关、推送时间、Webhook、加签密钥、
明细条数、@所有人、页面链接、上次推送结果、三段帮助说明
- 「测试通道」「预览正文」「保存」「立即推送到群」
- 预览改成弹窗内受控区块:本项目跑 React 19 + antd v5,Modal.info 静态方法
拿不到 context 会**静默不渲染**(只留一行 warning,看着像后端没返回)
同步与兼容
- 新配置首次读取为空 → ready=false,页面提示「Webhook 地址还没配」,不影响页面其它功能
- 定时任务只在 enabled=true 时生效;本地 ENABLE_BACKGROUND_JOBS=false 不跑,用页面按钮验
验证
- backend/test/warehouseLogisticsExceptionNotify.test.js 15/15:正文结构与数字、
0 条平安消息、条数上限、字节精简、Webhook 校验、加签 crypto 手算对齐、errcode 透传
- 后端全量 465 项 / 463 过(2 项既有环境失败:Windows 权限位、负缓存需隧道故障态)
- 前端 eslint 0、build 成功
- 真数据干跑:144 条异常 / 列 30 条 / 3768 字节 / 20s,按仓包裹数合计 144 对得上
- playwright 真渲染:弹窗字段 Webhook/加签密钥/明细条数/@所有人/页面链接,
旧字段 AppKey/AppSecret/robotCode/群会话 ID 零残留;预览内联渲染出正文
风险与回退
- 钉钉侧失败只会记 lastState.lastError 并在页面展示,不影响页面取数
- 回退:删除 index.js 里的 startWarehouseLogisticsExceptionNotifyScheduler 注册即可停推送
174 lines
6.7 KiB
JavaScript
174 lines
6.7 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 仓储中心物流转运异常 —— HTTP 路由(2026-10-21 新增)
|
||
* ────────────────────────────────────────────────────────────────────────────
|
||
* GET /api/warehouse-logistics-exception/list
|
||
* query:
|
||
* 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
|
||
*/
|
||
|
||
const express = require('express');
|
||
const {
|
||
getLogisticsExceptions,
|
||
getLogisticsExceptionExportRows,
|
||
} = require('../services/warehouseLogisticsExceptionService');
|
||
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();
|
||
|
||
function sendError(res, error, fallback) {
|
||
const status = Number(error?.statusCode) || 500;
|
||
console.error('[WarehouseLogisticsException]', {
|
||
message: error?.message || fallback,
|
||
code: error?.code || null,
|
||
status,
|
||
});
|
||
res.status(status).json({ success: false, error: error.message || fallback });
|
||
}
|
||
|
||
// 转运异常总览:12 个发货仓异常单量 + 明细
|
||
router.get('/list', async (req, res) => {
|
||
try {
|
||
const data = await getLogisticsExceptions(req.query || {});
|
||
res.json({ success: true, data });
|
||
} catch (error) {
|
||
sendError(res, error, '加载物流转运异常失败');
|
||
}
|
||
});
|
||
|
||
// 导出用:与 /list 同一套口径,只是把明细上限放开(不分页)
|
||
// 页面筛选(仓 / 异常类型)由前端用同一份过滤函数处理,保证导出的就是页面看到的
|
||
router.get('/export', async (req, res) => {
|
||
try {
|
||
const data = await getLogisticsExceptionExportRows(req.query || {});
|
||
res.json({ success: true, data });
|
||
} catch (error) {
|
||
sendError(res, error, '导出物流转运异常失败');
|
||
}
|
||
});
|
||
|
||
// 口径元数据(不查库,秒回):页面文案与图例用它,避免前后端各写一套
|
||
router.get('/meta', (_req, res) => {
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
verifyHour: VERIFY_HOUR,
|
||
lookbackDays: LOOKBACK_DAYS,
|
||
centers: CENTERS.map((c) => ({ key: c.key, name: c.name })),
|
||
exceptionTypes: EXCEPTION_TYPES,
|
||
},
|
||
});
|
||
});
|
||
|
||
// ── 每天推送到钉钉群 ────────────────────────────────────────────────────────
|
||
|
||
// 读配置: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;
|