'use strict'; // 每日进销存 · 定时截图通知 —— 配置 + 执行记录 + 幂等记录(本地库 system_config) // // ── 配置键 daily_inventory_notification_config ──────────────────── // { enabled, notifyTime: 'HH:mm', recipients: ['张三', '李四'] } // // ── 状态键 daily_inventory_notification_state ────────────────────── // { version, notified: { 'YYYY-MM-DD': { '别名': { notifiedAt, deliveryId } } }, runs: [...] } // · notified 用于「同一业务日期 + 同一用户只成功通知一次」的幂等判断 // · runs 保留最近 N 次执行明细,供管理员在页面查询 // // 安全约定:本文件对外只返回「是否已配置」这类布尔值与脱敏预览, // 绝不返回通知中心 projectSecret、对象存储 secretAccessKey 等密钥。 const database = require('../config/database'); const { getYydNotificationCenterRuntimeConfig, toAdminConfig: toNotificationCenterAdminConfig, } = require('./yydNotificationCenterSettings'); const { getDefaultS3Config } = require('./objectStorage'); const CONFIG_KEY = 'daily_inventory_notification_config'; const STATE_KEY = 'daily_inventory_notification_state'; const DEFAULT_NOTIFY_TIME = '01:00'; const TIMEZONE = 'Asia/Shanghai'; const MAX_RECIPIENTS = 20; const MAX_RECIPIENT_LENGTH = 50; const MAX_RUN_RECORDS = 30; const CONFIG_CACHE_TTL_MS = 30 * 1000; let cachedConfig = null; let cachedAt = 0; function normalizeText(value, maxLength = 200) { return String(value ?? '').trim().slice(0, maxLength); } function safeJsonParse(value, fallback = {}) { if (value && typeof value === 'object') return value; try { const parsed = JSON.parse(String(value || '')); return parsed && typeof parsed === 'object' ? parsed : fallback; } catch (_error) { return fallback; } } function normalizeFlag(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; } /** * 通知时间规范化:只接受 HH:mm(24 小时制),非法值回退默认 01:00。 * 允许传 'HH:mm:ss',截断到分钟。 */ function normalizeNotifyTime(value) { const text = normalizeText(value, 10); const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(text); if (!match) return DEFAULT_NOTIFY_TIME; const hour = Number(match[1]); const minute = Number(match[2]); if (!Number.isInteger(hour) || hour < 0 || hour > 23) return DEFAULT_NOTIFY_TIME; if (!Number.isInteger(minute) || minute < 0 || minute > 59) return DEFAULT_NOTIFY_TIME; return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; } /** * 收件人名单规范化:trim → 去空 → 去重(保持输入顺序)→ 限长限量。 * 支持数组、逗号/换行/分号分隔的字符串。 */ function normalizeRecipients(input) { let list = []; if (Array.isArray(input)) { list = input; } else if (typeof input === 'string') { list = input.split(/[,,;;\n\r\t]+/); } else if (input && typeof input === 'object') { list = Object.values(input); } const seen = new Set(); const result = []; for (const raw of list) { const name = normalizeText(raw, MAX_RECIPIENT_LENGTH); if (!name) continue; if (seen.has(name)) continue; seen.add(name); result.push(name); if (result.length >= MAX_RECIPIENTS) break; } return result; } /** 幂等键:正式推送按「业务日期 + 收件人」去重;测试推送带时间戳,不污染正式记录 */ function buildIdempotencyKey({ bizDate, recipient, mode = 'scheduled', nonce = '' }) { const scope = mode === 'test' ? `test:${normalizeText(nonce, 64)}` : 'daily'; return `daily_inventory_notification:${scope}:${bizDate}:${recipient}:v1`.slice(0, 160); } async function readSystemConfigValue(key) { const rows = await database.local.query( 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', [key] ); return rows?.[0]?.configValue; } async function upsertSystemConfigValue(key, value, configType = 'json') { const payload = typeof value === 'string' ? value : JSON.stringify(value); const rows = await database.local.query( 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', [key] ); if (rows?.[0]) { await database.local.query( 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', [payload, configType, key] ); } else { await database.local.insert('system_config', { config_key: key, config_value: payload, config_type: configType, }); } return payload; } // ── 配置 ──────────────────────────────────────────────────────── async function getDailyInventoryNotificationRuntimeConfig({ forceRefresh = false } = {}) { if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) { return { ...cachedConfig }; } let stored = {}; try { stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {}); } catch (error) { console.warn('读取每日进销存通知配置失败,使用默认值:', error.message); } cachedConfig = { enabled: normalizeFlag(stored.enabled, false), notifyTime: normalizeNotifyTime(stored.notifyTime ?? stored.notify_time), recipients: normalizeRecipients(stored.recipients), }; cachedAt = Date.now(); return { ...cachedConfig }; } async function saveDailyInventoryNotificationConfig(input = {}) { const current = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true }); const next = { enabled: normalizeFlag(input.enabled, current.enabled), notifyTime: normalizeNotifyTime( input.notifyTime ?? input.notify_time ?? current.notifyTime ), recipients: normalizeRecipients( input.recipients === undefined ? current.recipients : input.recipients ), }; await upsertSystemConfigValue(CONFIG_KEY, next); cachedConfig = { ...next }; cachedAt = Date.now(); return { ...next }; } // ── 执行记录 / 幂等记录 ───────────────────────────────────────── async function loadDailyInventoryNotificationState() { let parsed = {}; try { parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {}); } catch (error) { console.warn('读取每日进销存通知状态失败,按空状态处理:', error.message); } const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified) ? parsed.notified : {}; const runs = Array.isArray(parsed.runs) ? parsed.runs : []; return { version: 1, notified, runs }; } async function saveDailyInventoryNotificationState(state = {}) { const value = { version: 1, notified: state.notified && typeof state.notified === 'object' && !Array.isArray(state.notified) ? state.notified : {}, runs: Array.isArray(state.runs) ? state.runs.slice(-MAX_RUN_RECORDS) : [], }; await upsertSystemConfigValue(STATE_KEY, value); return value; } /** 判断某业务日期是否已成功通知过某人(只认正式推送) */ async function hasNotified(bizDate, recipient) { const state = await loadDailyInventoryNotificationState(); const day = state.notified?.[bizDate]; return Boolean(day && day[recipient]); } /** 记录一次成功通知(正式推送才写;测试推送不写) */ async function markNotified(bizDate, recipient, extra = {}) { const state = await loadDailyInventoryNotificationState(); const day = { ...(state.notified?.[bizDate] || {}) }; day[recipient] = { notifiedAt: new Date().toISOString(), deliveryId: extra.deliveryId || null, idempotencyKey: extra.idempotencyKey || null, }; const notified = { ...(state.notified || {}), [bizDate]: day }; // 只保留最近 14 个业务日,避免状态无限膨胀 const keys = Object.keys(notified).sort(); const trimmed = {}; keys.slice(-14).forEach((k) => { trimmed[k] = notified[k]; }); return saveDailyInventoryNotificationState({ ...state, notified: trimmed }); } /** 追加一条执行记录 */ async function appendRunRecord(record = {}) { const state = await loadDailyInventoryNotificationState(); const runs = [...(state.runs || []), { runAt: record.runAt || new Date().toISOString(), bizDate: record.bizDate || null, mode: record.mode || 'scheduled', success: record.success === true, reason: record.reason || null, error: record.error || null, screenshotUrl: record.screenshotUrl || null, screenshotKey: record.screenshotKey || null, notifyTime: record.notifyTime || null, recipients: Array.isArray(record.recipients) ? record.recipients : [], }]; return saveDailyInventoryNotificationState({ ...state, runs }); } function listRunRecords(limit = 10) { return loadDailyInventoryNotificationState().then((state) => { const runs = Array.isArray(state.runs) ? state.runs : []; return runs.slice(-Math.max(Number(limit) || 10, 1)).reverse(); }); } // ── 管理员视图(不含任何密钥) ─────────────────────────────────── async function getObjectStorageStatus() { try { const config = await getDefaultS3Config(false); return { configured: Boolean(config && config.bucket && config.endpoint && config.accessKeyId), bucket: config?.bucket || '', endpoint: config?.endpoint || '', // 只给脱敏信息,secretAccessKey 从不读取、从不返回 accessKeyIdPreview: config?.accessKeyId ? `${String(config.accessKeyId).slice(0, 4)}***` : '', }; } catch (error) { return { configured: false, bucket: '', endpoint: '', accessKeyIdPreview: '', error: error.message }; } } async function getDailyInventoryNotificationAdminConfig() { const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true }); let center = {}; try { center = await getYydNotificationCenterRuntimeConfig(); } catch (_error) { center = {}; } const centerAdmin = toNotificationCenterAdminConfig(center); const storage = await getObjectStorageStatus(); const state = await loadDailyInventoryNotificationState(); const runs = Array.isArray(state.runs) ? state.runs : []; const blockingReasons = []; if (!config.enabled) blockingReasons.push('功能未启用'); if (!config.recipients.length) blockingReasons.push('未配置通知用户'); if (!centerAdmin.configured) blockingReasons.push('统一通知中心未配置'); if (!storage.configured) blockingReasons.push('对象存储未配置'); return { enabled: config.enabled, notifyTime: config.notifyTime, recipients: config.recipients, timezone: TIMEZONE, maxRecipients: MAX_RECIPIENTS, notificationCenter: { configured: centerAdmin.configured, baseUrl: centerAdmin.baseUrl, projectCode: centerAdmin.projectCode, projectSecretConfigured: centerAdmin.projectSecretConfigured, projectSecretPreview: centerAdmin.projectSecretPreview, }, objectStorage: storage, blockingReasons, lastRunAt: runs.length ? runs[runs.length - 1].runAt : null, lastRunSuccess: runs.length ? runs[runs.length - 1].success === true : null, runs: runs.slice(-10).reverse(), }; } module.exports = { CONFIG_KEY, STATE_KEY, DEFAULT_NOTIFY_TIME, MAX_RECIPIENTS, MAX_RECIPIENT_LENGTH, MAX_RUN_RECORDS, TIMEZONE, appendRunRecord, buildIdempotencyKey, getDailyInventoryNotificationAdminConfig, getDailyInventoryNotificationRuntimeConfig, getObjectStorageStatus, hasNotified, listRunRecords, loadDailyInventoryNotificationState, markNotified, normalizeFlag, normalizeNotifyTime, normalizeRecipients, normalizeText, saveDailyInventoryNotificationConfig, saveDailyInventoryNotificationState, };