forked from lunanting/cangku
在「仓储中心物流转运异常」页新增「异常通知」:每天 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 注册即可停推送
211 lines
8.5 KiB
JavaScript
211 lines
8.5 KiB
JavaScript
'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,
|
||
};
|