'use strict'; /** * 「仓储中心发货时效」两轮核验完成后推文本到钉钉群 —— 配置与状态存取 * ──────────────────────────────────────────────────────────────────────────── * 业务口径(2026-10-27): * 「13 点核验 7 点前的超时单,核验完成后通知;18:00 核验的订单,核验完成后也通知。」 * ⟹ **一天两推**,各自绑一个核验轮次: * NOON 13:00 核验(窗口上界 07:00 前审核)→ 默认 13:05 推 * AFTERNOON 18:00 核验(窗口上界 16:00 前审核)→ 默认 18:05 推 * ⚠️ 默认给到「核验时刻 + 5 分钟」而不是整点:状态镜像 `query_time` 要略晚于核验时刻, * 否则 `freshness.coversDeadline=false`(数据没覆盖本轮核验点),推出去的超时数必然偏大。 * 业务要改时刻在页面上改,不必重启后端。 * * 通道 = 钉钉群自定义机器人 webhook(与「物流转运异常」同一套,只发 text、不发附件)。 * 配置分两层存(与 yydNotificationCenterSettings 同一套做法): * · 普通字段 → 本地库 `system_config`,key = warehouse_ship_timeliness_notify_config * · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件) * * 状态另存一份(key = warehouse_ship_timeliness_notify_state),按 `<日期>|<轮次键>` 去重 —— * 服务重启、每分钟 tick 重复触发都不会重复轰炸;页面「立即推送」才强制重发。 */ const database = require('../config/database'); const { getResilientSecret, saveResilientSecret, } = require('./resilientSecretStore'); const CONFIG_KEY = 'warehouse_ship_timeliness_notify_config'; const SECRET_KEY = 'warehouse_ship_timeliness_notify_webhook_secret'; const STATE_KEY = 'warehouse_ship_timeliness_notify_state'; // 两个轮次的默认推送时刻(核验 13:00 / 18:00 + 5 分钟,见文件头说明) const DEFAULT_SEND_TIME_NOON = '13:05'; const DEFAULT_SEND_TIME_AFTERNOON = '18:05'; // 明细条数:text 上限 20000 字节,30 条中文明细约 9~12KB,留足余量 const DEFAULT_MAX_DETAIL_ROWS = 30; const MAX_DETAIL_ROWS_LIMIT = 50; // 页面地址(消息末尾「查看全部」用)。生产入口带 8443,别写成裸域名 const DEFAULT_PAGE_URL = process.env.WST_PAGE_URL || 'https://ck.yydd.ai:8443/planning/operation/warehouse-ship-timeliness'; const CACHE_TTL_MS = 30 * 1000; // 状态里最多保留多少条「日期|轮次」记录(一天两条,30 天足够,避免 system_config 无限长) const MAX_STATE_ENTRIES = 60; 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);非法输入退回默认值,不抛错(避免把定时任务配挂) */ function normalizeSendTime(value, fallback = DEFAULT_SEND_TIME_NOON) { const text = normalizeText(value, 5); if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text; return fallback; } /** 明细条数: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; } 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('[WstNotify] 读取推送配置失败:', error.message); return {}; } } /** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */ async function getShipTimelinessNotifyRuntimeConfig({ 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.WST_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)], }); cachedConfig = { enabled: normalizeBoolean(stored.enabled, false), webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024) || normalizeText(process.env.WST_NOTIFY_DINGTALK_WEBHOOK, 1024), webhookSecret: secret.value, webhookSecretSource: secret.source, // 两轮各自的推送时刻 noonSendTime: normalizeSendTime( stored.noonSendTime || stored.noon_send_time, DEFAULT_SEND_TIME_NOON ), afternoonSendTime: normalizeSendTime( stored.afternoonSendTime || stored.afternoon_send_time, DEFAULT_SEND_TIME_AFTERNOON ), 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 isShipTimelinessNotifyReady(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', noonSendTime: normalizeSendTime(config.noonSendTime, DEFAULT_SEND_TIME_NOON), afternoonSendTime: normalizeSendTime(config.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON), maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows), maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT, atAll: config.atAll === true, pageUrl: normalizePageUrl(config.pageUrl), ready: isShipTimelinessNotifyReady(config), missingFields: missingFields(config), source: config.source || 'default', }; } async function getShipTimelinessNotifyAdminConfig() { const config = await getShipTimelinessNotifyRuntimeConfig({ forceRefresh: true }); return toAdminConfig(config); } /** * 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填); * 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。 * 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。 */ async function saveShipTimelinessNotifyConfig(input = {}) { const current = await getShipTimelinessNotifyRuntimeConfig({ 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), noonSendTime: input.noonSendTime === undefined && input.noon_send_time === undefined ? normalizeSendTime(current.noonSendTime, DEFAULT_SEND_TIME_NOON) : normalizeSendTime(input.noonSendTime ?? input.noon_send_time, DEFAULT_SEND_TIME_NOON), afternoonSendTime: input.afternoonSendTime === undefined && input.afternoon_send_time === undefined ? normalizeSendTime(current.afternoonSendTime, DEFAULT_SEND_TIME_AFTERNOON) : normalizeSendTime(input.afternoonSendTime ?? input.afternoon_send_time, DEFAULT_SEND_TIME_AFTERNOON), 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, noonSendTime: config.noonSendTime, afternoonSendTime: config.afternoonSendTime, 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); } /** * 推送状态。 * 结构(version 1): * { * version: 1, * sent: { '2026-10-27|NOON': { sentAt, rowCount, listedCount, bytes, deadline } , ... }, * lastSentAt, lastStatus, lastError, lastFailedAt, lastRoundLabel * } * sent 只保留最近 MAX_STATE_ENTRIES 条(一天两轮,30 天足够)。 */ async function loadShipTimelinessNotifyState() { 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, {}); if (!state || typeof state !== 'object') return {}; return { ...state, sent: (state.sent && typeof state.sent === 'object') ? state.sent : {} }; } catch (error) { console.warn('[WstNotify] 读取推送状态失败:', error.message); return {}; } } /** 去重键:`<北京日期>|<轮次键>` —— 一天两轮各自独立 */ function buildPushSlot(dateKey, roundKey) { const date = normalizeText(dateKey, 10); const round = normalizeText(roundKey, 20).toUpperCase(); if (!date || !round) return ''; return `${date}|${round}`; } async function saveShipTimelinessNotifyState(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('[WstNotify] 保存推送状态失败:', error.message); } } /** 把 sent 裁剪到 MAX_STATE_ENTRIES 条,按 slot 日期倒序保留(新日期优先) */ function trimSentMap(sent = {}) { const entries = Object.entries(sent || {}); if (entries.length <= MAX_STATE_ENTRIES) return sent; entries.sort((a, b) => { const da = String(a[1]?.sentAt || ''); const db = String(b[1]?.sentAt || ''); return db.localeCompare(da); }); return Object.fromEntries(entries.slice(0, MAX_STATE_ENTRIES)); } function clearShipTimelinessNotifyConfigCache() { cachedConfig = null; cachedAt = 0; } module.exports = { CONFIG_KEY, DEFAULT_MAX_DETAIL_ROWS, DEFAULT_PAGE_URL, DEFAULT_SEND_TIME_AFTERNOON, DEFAULT_SEND_TIME_NOON, MAX_DETAIL_ROWS_LIMIT, MAX_STATE_ENTRIES, SECRET_KEY, STATE_KEY, buildPushSlot, clearShipTimelinessNotifyConfigCache, getShipTimelinessNotifyAdminConfig, getShipTimelinessNotifyRuntimeConfig, isShipTimelinessNotifyReady, loadShipTimelinessNotifyState, maskSecret, missingFields, normalizeMaxDetailRows, normalizePageUrl, normalizeSendTime, saveShipTimelinessNotifyConfig, saveShipTimelinessNotifyState, toAdminConfig, trimSentMap, };