forked from lunanting/cangku
设置(仅管理员) - 可配置是否启用、通知时间(默认 01:00 Asia/Shanghai)、多名通知用户 - 收件人在后端规范化:去空、去重、限长限量,配置持久化到 system_config - 接口不返回通知中心密钥与对象存储密钥,只给是否已配置与脱敏预览 定时任务 - 单实例锁(MySQL GET_LOCK),按配置时间触发,命中后 30 分钟内失败最多补跑 3 次 - 服务端签发 15 分钟短时效 JWT 作为专用身份,令牌只写入浏览器 localStorage - 等数据真正渲染完成(有数据行、无加载中、无错误提示)才截图,展开滚动容器后整页截图 - 对象键形如 daily-inventory/2026-09-25/daily-inventory-20260925-010000.png - 截图上传成功后才逐用户发通知,单人失败不影响他人;正式推送按「业务日期+收件人」幂等 - 立即测试走同一链路但幂等键带随机数,不影响次日正式推送 - 每次执行记录时间、截图地址、通知对象、成功状态与失败原因 测试 - 覆盖收件人去重、通知时间校验、幂等键、业务日期与对象键、重试退避(17 项)
331 lines
12 KiB
JavaScript
331 lines
12 KiB
JavaScript
'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,
|
||
};
|