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