'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, };