From 56fba389e432cae821bb7b32bf268fab335399e3 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Thu, 24 Sep 2026 11:22:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=AF=8F=E6=97=A5=E8=BF=9B?= =?UTF-8?q?=E9=94=80=E5=AD=98=E5=A2=9E=E5=8A=A0=E5=AE=9A=E6=97=B6=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置(仅管理员) - 可配置是否启用、通知时间(默认 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 项) --- backend/src/api/dailyInventoryNotification.js | 81 ++++ backend/src/index.js | 10 + .../src/jobs/dailyInventoryNotificationJob.js | 349 ++++++++++++++++++ .../dailyInventoryNotificationSettings.js | 330 +++++++++++++++++ .../src/services/dailyInventoryScreenshot.js | 216 +++++++++++ .../test/dailyInventoryNotification.test.js | 165 +++++++++ .../src/api/dailyInventoryNotification.js | 17 + frontend/src/pages/DailyInventory/index.jsx | 299 ++++++++++++++- frontend/src/pages/DailyInventory/style.css | 53 +++ 9 files changed, 1518 insertions(+), 2 deletions(-) create mode 100644 backend/src/api/dailyInventoryNotification.js create mode 100644 backend/src/jobs/dailyInventoryNotificationJob.js create mode 100644 backend/src/services/dailyInventoryNotificationSettings.js create mode 100644 backend/src/services/dailyInventoryScreenshot.js create mode 100644 backend/test/dailyInventoryNotification.test.js create mode 100644 frontend/src/api/dailyInventoryNotification.js diff --git a/backend/src/api/dailyInventoryNotification.js b/backend/src/api/dailyInventoryNotification.js new file mode 100644 index 0000000..29db846 --- /dev/null +++ b/backend/src/api/dailyInventoryNotification.js @@ -0,0 +1,81 @@ +'use strict'; + +// 每日进销存 · 定时截图通知(管理员专用) +// +// 安全约定: +// · 全部接口要求登录且 role === 'admin' +// · 响应里只有「是否已配置」与脱敏预览,绝不返回通知中心密钥 / 对象存储密钥 +// · 收件人名单由后端校验与规范化,前端只能提交姓名文本 + +const express = require('express'); +const { + getDailyInventoryNotificationAdminConfig, + listRunRecords, + saveDailyInventoryNotificationConfig, +} = require('../services/dailyInventoryNotificationSettings'); +const { + runDailyInventoryNotificationJob, +} = require('../jobs/dailyInventoryNotificationJob'); + +const router = express.Router(); + +function sendError(res, error, fallback) { + const status = Number(error?.statusCode) || 500; + console.error('[DailyInventoryNotification]', { + message: error?.message || fallback, + code: error?.code || null, + status, + }); + res.status(status).json({ success: false, error: error.message || fallback }); +} + +/** 仅管理员可查看与修改通知设置、执行立即测试 */ +function requireAdmin(req, res, next) { + if (req?.user?.role !== 'admin') { + return res.status(403).json({ success: false, error: '仅管理员可操作通知设置' }); + } + return next(); +} + +router.use(requireAdmin); + +// 读取通知设置(含通知中心 / 对象存储的配置状态与最近执行记录) +router.get('/config', async (_req, res) => { + try { + res.json({ success: true, data: await getDailyInventoryNotificationAdminConfig() }); + } catch (error) { + sendError(res, error, '加载每日进销存通知设置失败'); + } +}); + +// 保存通知设置 +router.put('/config', async (req, res) => { + try { + const saved = await saveDailyInventoryNotificationConfig(req.body || {}); + res.json({ success: true, data: { ...saved, admin: await getDailyInventoryNotificationAdminConfig() } }); + } catch (error) { + sendError(res, error, '保存每日进销存通知设置失败'); + } +}); + +// 立即测试:真实走一次截图 + 上传 + 通知,但不写入正式幂等记录 +router.post('/test', async (_req, res) => { + try { + const result = await runDailyInventoryNotificationJob({ mode: 'test' }); + res.json({ success: result?.success === true, data: result }); + } catch (error) { + sendError(res, error, '执行每日进销存通知测试失败'); + } +}); + +// 最近执行记录(管理员排障用) +router.get('/runs', async (req, res) => { + try { + const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 50); + res.json({ success: true, data: await listRunRecords(limit) }); + } catch (error) { + sendError(res, error, '加载每日进销存通知执行记录失败'); + } +}); + +module.exports = router; diff --git a/backend/src/index.js b/backend/src/index.js index a21cbed..1308ad3 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -36,6 +36,7 @@ const productMonitoringRoutes = require('./api/productMonitoring'); const modularStockMonitorRoutes = require('./api/modularStockMonitor'); const mkhBomRoutes = require('./api/mkhBom'); const dailyInventoryRoutes = require('./api/dailyInventory'); +const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification'); const database = require('./config/database'); const cloudDb = require('./config/cloudDatabase'); const jianSunDb = require('./config/jianSunDatabase'); @@ -51,6 +52,9 @@ const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); const { startDailyInventorySnapshotScheduler, } = require('./jobs/dailyInventorySnapshotJob'); +const { + startDailyInventoryNotificationScheduler, +} = require('./jobs/dailyInventoryNotificationJob'); const { startAlibaba1688ImageSearchExpiryScheduler, } = require('./jobs/alibaba1688ImageSearchExpiryJob'); @@ -663,6 +667,7 @@ async function startServer(options = {}) { cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); + cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes); cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); // ===================== @@ -718,6 +723,11 @@ async function startServer(options = {}) { console.log(` [缓存] ${redisTest.message}`); console.log('='.repeat(50)); + // 每日进销存截图通知:与「后台定时任务总开关」解耦,保证即使总开关关闭, + // 只要功能启用就仍会按配置时间推送(可用 DAILY_INVENTORY_NOTIFICATION_ENABLED=false 单独关闭)。 + // 它每分钟只做一次轻量判断,命中配置时间才真正截图上传。 + startDailyInventoryNotificationScheduler(); + if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') { console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理'); return; diff --git a/backend/src/jobs/dailyInventoryNotificationJob.js b/backend/src/jobs/dailyInventoryNotificationJob.js new file mode 100644 index 0000000..f397eca --- /dev/null +++ b/backend/src/jobs/dailyInventoryNotificationJob.js @@ -0,0 +1,349 @@ +'use strict'; + +// 每日进销存 · 定时截图通知 +// +// 执行流程: +// ① 单实例锁(MySQL GET_LOCK),避免多实例重复截图推送 +// ② 校验:功能开启 / 有收件人 / 通知中心已配置 / 对象存储已配置,任一不满足就记录原因并退出 +// ③ 按配置时间(默认 01:00 Asia/Shanghai)触发;业务日期 = 北京时间昨日(与页面 T-1 口径一致) +// ④ 截图 → 上传对象存储 → 拿到公开 HTTPS 地址后才发通知 +// ⑤ 逐用户发送,用「业务日期 + 收件人」幂等键去重;单人失败不影响其他人 +// ⑥ 截图/上传/通知失败均做有限次数重试与指数退避 +// ⑦ 每次执行都落一条记录(时间 / 截图地址 / 通知对象 / 成功状态 / 失败原因) +// +// 立即测试(mode='test'):走同一条链路,但幂等键带随机数,**不写入正式幂等记录**, +// 因此不会影响下一次正式定时推送。 + +const crypto = require('crypto'); +const cron = require('node-cron'); +const database = require('../config/database'); +const { + appendRunRecord, + buildIdempotencyKey, + getDailyInventoryNotificationRuntimeConfig, + getObjectStorageStatus, + hasNotified, + loadDailyInventoryNotificationState, + markNotified, + normalizeRecipients, +} = require('../services/dailyInventoryNotificationSettings'); +const { + captureAndUploadDailyInventory, + chinaTimeParts, + resolvePageUrl, +} = require('../services/dailyInventoryScreenshot'); +const { + getYydNotificationCenterRuntimeConfig, + isYydNotificationCenterConfigured, + sendYydNotification, +} = require('../services/yydNotificationCenterClient'); +const TIMEZONE = 'Asia/Shanghai'; +const LOCK_KEY = 'daily_inventory_notification_job'; +const LOCK_WAIT_SECONDS = 2; +const NOTIFICATION_TITLE = '每日进销存数据'; +const ACTION_TITLE = '查看每日进销存'; + +// 重试策略:最多 3 次,退避 2s / 4s +const MAX_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 2000; +// 命中时间后允许补跑的窗口与次数上限(防止全天每分钟重试刷屏) +const RETRY_WINDOW_MINUTES = 30; +const MAX_SCHEDULED_ATTEMPTS_PER_DAY = 3; + +let running = false; +let timer = null; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** 有限次数重试 + 指数退避 */ +async function withRetry(task, { label, attempts = MAX_ATTEMPTS, baseDelayMs = RETRY_BASE_DELAY_MS } = {}) { + let lastError = null; + for (let index = 0; index < attempts; index += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + const delay = baseDelayMs * (2 ** index); + console.error( + `[DailyInventoryNotify] ${label} 第 ${index + 1}/${attempts} 次失败:${error.message}` + + `${index < attempts - 1 ? `,${delay}ms 后重试` : ''}` + ); + if (index < attempts - 1) await sleep(delay); + } + } + throw lastError; +} + +/** 业务日期 = 北京时间昨日(页面标注 T-1,凌晨跑批产出昨日快照) */ +function resolveBizDate(now = new Date()) { + const target = now instanceof Date ? now : new Date(now); + const shifted = new Date(target.getTime() - 24 * 60 * 60 * 1000); + return chinaTimeParts(shifted).bizDate; +} + +function beijingTimeText(date = new Date()) { + const target = date instanceof Date ? date : new Date(date); + return new Intl.DateTimeFormat('zh-CN', { + timeZone: TIMEZONE, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }).format(target); +} + +function currentBeijingHhMm(now = new Date()) { + const text = new Intl.DateTimeFormat('en-GB', { + timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', hour12: false, + }).format(now instanceof Date ? now : new Date(now)); + return text; +} + +/** 全实例锁:拿到锁才继续执行,拿不到说明其它实例正在跑 */ +async function withJobLock(runner) { + const pool = database.local.pool; + let connection = null; + try { + connection = await pool.getConnection(); + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [LOCK_KEY, LOCK_WAIT_SECONDS]); + const acquired = Number(rows?.[0]?.lockStatus || 0) === 1; + if (!acquired) { + return { success: false, skipped: true, reason: 'lock_not_acquired' }; + } + return await runner(); + } catch (error) { + return { success: false, skipped: true, reason: 'lock_error', error: error.message }; + } finally { + if (connection) { + try { + await connection.query('DO RELEASE_LOCK(?)', [LOCK_KEY]); + } catch (_error) { + // ignore + } + connection.release(); + } + } +} + +function buildNotificationContent({ bizDate, takenAt, imageUrl, pageUrl }) { + return [ + `**数据日期:** ${bizDate}`, + `**截图生成时间:** ${takenAt}(${TIMEZONE})`, + `**图片地址:** ${imageUrl}`, + '', + `点击右下角「${ACTION_TITLE}」可直达每日进销存页面。`, + ].join('\n'); +} + +/** + * 执行一次(正式 / 测试) + * @param {Object} options + * mode 'scheduled' | 'manual' | 'test' + * force true 时忽略「今天已调度过」的节流判断(手动触发用) + * now 指定执行时刻,便于测试 + */ +async function runDailyInventoryNotificationJob(options = {}) { + const mode = options.mode || 'scheduled'; + const now = options.now ? new Date(options.now) : new Date(); + const startedAt = now.toISOString(); + + if (running) { + return { success: false, skipped: true, reason: 'already_running' }; + } + running = true; + + try { + return await withJobLock(async () => { + const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true }); + const recipients = normalizeRecipients(config.recipients); + const bizDate = resolveBizDate(now); + + const finish = async (payload) => { + await appendRunRecord({ ...payload, runAt: startedAt, bizDate, mode, notifyTime: config.notifyTime }); + return payload; + }; + + if (!config.enabled) { + return finish({ success: false, reason: 'disabled', error: '功能未启用' }); + } + if (!recipients.length) { + return finish({ success: false, reason: 'no_recipient', error: '未配置通知用户' }); + } + + let centerConfig = null; + try { + centerConfig = await getYydNotificationCenterRuntimeConfig(); + } catch (_error) { + centerConfig = null; + } + if (!isYydNotificationCenterConfigured(centerConfig)) { + return finish({ success: false, reason: 'notification_center_not_configured', error: '统一通知中心未配置' }); + } + + const storage = await getObjectStorageStatus(); + if (!storage.configured) { + return finish({ success: false, reason: 'object_storage_not_configured', error: '对象存储未配置' }); + } + + // 截图 → 上传(失败直接结束,绝不发通知) + let shot; + try { + shot = await withRetry( + () => captureAndUploadDailyInventory({ now }), + { label: '截图并上传' } + ); + } catch (error) { + return finish({ success: false, reason: 'screenshot_failed', error: error.message }); + } + + const pageUrl = resolvePageUrl(); + const takenAt = beijingTimeText(now); + const nonce = crypto.randomBytes(8).toString('hex'); + + const deliveries = []; + for (const recipient of recipients) { + const idempotencyKey = buildIdempotencyKey({ bizDate, recipient, mode, nonce }); + // 正式推送先查幂等:同一业务日期 + 同一用户只成功通知一次 + if (mode !== 'test' && await hasNotified(bizDate, recipient)) { + deliveries.push({ recipient, status: 'skipped', reason: 'already_notified' }); + continue; + } + try { + const response = await withRetry( + () => sendYydNotification({ + idempotencyKey, + recipientAlias: recipient, + title: NOTIFICATION_TITLE, + content: buildNotificationContent({ + bizDate, takenAt, imageUrl: shot.url, pageUrl, + }), + actionTitle: ACTION_TITLE, + actionUrl: pageUrl, + }), + { label: `通知 ${recipient}` } + ); + const deliveryId = response?.id || response?.data?.id || null; + if (mode !== 'test') await markNotified(bizDate, recipient, { deliveryId, idempotencyKey }); + deliveries.push({ recipient, status: 'sent', deliveryId }); + } catch (error) { + // 单人失败不阻断其他人 + console.error(`[DailyInventoryNotify] 通知 ${recipient} 失败:${error.message}`); + deliveries.push({ recipient, status: 'failed', error: error.message }); + } + } + + const sentCount = deliveries.filter((d) => d.status === 'sent').length; + const failedCount = deliveries.filter((d) => d.status === 'failed').length; + const success = sentCount > 0; + + console.log( + `[DailyInventoryNotify] ${mode} 完成:业务日 ${bizDate},截图 ${shot.url},` + + `通知成功 ${sentCount} / 失败 ${failedCount} / 跳过 ${deliveries.length - sentCount - failedCount}` + ); + + return finish({ + success, + reason: success ? null : 'notify_failed', + error: success ? null : '所有收件人均通知失败', + screenshotUrl: shot.url, + screenshotKey: shot.key, + rowCount: shot.rowCount, + recipients: deliveries, + }); + }); + } finally { + running = false; + } +} + +/** 同一业务日期已经发起过的正式调度次数与最后一次结果(用于失败重试节流) */ +async function scheduledAttemptStatus(bizDate) { + const state = await loadDailyInventoryNotificationState(); + const runs = (Array.isArray(state.runs) ? state.runs : []) + .filter((r) => r.bizDate === bizDate && r.mode === 'scheduled'); + return { + count: runs.length, + lastSuccess: runs.length ? runs[runs.length - 1].success === true : null, + }; +} + +function minutesOfDay(hhmm) { + const match = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || '')); + if (!match) return null; + const hour = Number(match[1]); + const minute = Number(match[2]); + if (hour > 23 || minute > 59) return null; + return hour * 60 + minute; +} + +/** + * 是否应当发起一次正式调度: + * · 恰好命中配置时间 → 执行 + * · 命中时间之后的 RETRY_WINDOW_MINUTES 分钟内 → 仅在上一次失败且未超出重试上限时补跑 + * · 其余时间 → 不执行 + */ +async function shouldRunScheduled(config, now) { + const target = minutesOfDay(config.notifyTime); + const current = minutesOfDay(currentBeijingHhMm(now)); + if (target === null || current === null) return false; + + const diff = current - target; + if (diff < 0 || diff > RETRY_WINDOW_MINUTES) return false; + if (diff === 0) return true; + + const status = await scheduledAttemptStatus(resolveBizDate(now)); + return status.lastSuccess === false && status.count < MAX_SCHEDULED_ATTEMPTS_PER_DAY; +} + +function startDailyInventoryNotificationScheduler() { + const disabled = String(process.env.DAILY_INVENTORY_NOTIFICATION_ENABLED || 'true').trim().toLowerCase() === 'false'; + if (disabled) { + console.log('[DailyInventoryNotify] 已通过环境变量关闭'); + return null; + } + if (timer) return timer; + // 每分钟检查一次:命中配置的通知时间才真正执行,配置改动无需重启 + timer = cron.schedule('* * * * *', async () => { + try { + const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true }); + if (!config.enabled) return; + const now = new Date(); + if (!(await shouldRunScheduled(config, now))) return; + await runDailyInventoryNotificationJob({ mode: 'scheduled', now }); + } catch (error) { + console.error('[DailyInventoryNotify] 定时检查失败:', error.message); + } + }, { timezone: TIMEZONE }); + + console.log( + `[Scheduler] 每日进销存截图通知已注册(按配置时间触发,${TIMEZONE},` + + `失败后 ${RETRY_WINDOW_MINUTES} 分钟内最多补跑 ${MAX_SCHEDULED_ATTEMPTS_PER_DAY} 次)` + ); + return timer; +} + +function stopDailyInventoryNotificationScheduler() { + if (timer) { + timer.stop(); + timer = null; + } +} + +module.exports = { + ACTION_TITLE, + LOCK_KEY, + MAX_ATTEMPTS, + MAX_SCHEDULED_ATTEMPTS_PER_DAY, + NOTIFICATION_TITLE, + RETRY_BASE_DELAY_MS, + TIMEZONE, + RETRY_WINDOW_MINUTES, + beijingTimeText, + buildNotificationContent, + currentBeijingHhMm, + minutesOfDay, + resolveBizDate, + runDailyInventoryNotificationJob, + startDailyInventoryNotificationScheduler, + stopDailyInventoryNotificationScheduler, + withRetry, +}; diff --git a/backend/src/services/dailyInventoryNotificationSettings.js b/backend/src/services/dailyInventoryNotificationSettings.js new file mode 100644 index 0000000..ae46247 --- /dev/null +++ b/backend/src/services/dailyInventoryNotificationSettings.js @@ -0,0 +1,330 @@ +'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, +}; diff --git a/backend/src/services/dailyInventoryScreenshot.js b/backend/src/services/dailyInventoryScreenshot.js new file mode 100644 index 0000000..e82bdd3 --- /dev/null +++ b/backend/src/services/dailyInventoryScreenshot.js @@ -0,0 +1,216 @@ +'use strict'; + +// 每日进销存 · 页面截图与上传 +// +// 要点: +// 1. 使用服务端签发的**短时效** JWT 作为专用身份访问页面(15 分钟过期), +// 令牌只写入浏览器 localStorage,不会出现在截图地址、日志或接口响应里。 +// 2. 截图前必须等到数据真正加载完成:表格有数据行、没有 Spin 加载中、没有错误提示。 +// 3. 表格带内部滚动,直接截屏只能看到一屏;截图前注入 CSS 展开滚动容器, +// 再整页截图,保证数据是完整的。 +// 4. 截图落在系统临时目录,上传成功后立即删除。 + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const jwt = require('jsonwebtoken'); +const { uploadFileToDefaultS3 } = require('./objectStorage'); + +const TIMEZONE = 'Asia/Shanghai'; +const PAGE_PATH = '/planning/operation/daily-inventory'; +const DEFAULT_BASE_URL = 'https://pddg.jiansuntuzhi.cn'; +// 页面首次加载要扫订单明细,约 15~20 秒,等待上限给到 3 分钟 +const PAGE_READY_TIMEOUT_MS = 3 * 60 * 1000; +const SCREENSHOT_SERVICE_USER = Object.freeze({ + sub: 'daily-inventory-screenshot-bot', + username: 'daily_inventory_screenshot_bot', + name: '每日进销存截图服务', + role: 'admin', + authSource: 'service', + allowedPageKeys: [], +}); + +const EXPAND_CSS = ` +.ant-table-body, .ant-table-content, .ant-table-container { max-height: none !important; overflow: visible !important; } +.daily-inventory-page { overflow: visible !important; } +.ant-table-cell-fix-left, .ant-table-cell-fix-right { position: static !important; } +`; + +function normalizeText(value, maxLength = 500) { + return String(value ?? '').trim().slice(0, maxLength); +} + +/** 北京时间日期/时间片段:YYYY-MM-DD、YYYYMMDD、HHmmss */ +function chinaTimeParts(date = new Date()) { + const target = date instanceof Date ? date : new Date(date); + const dateText = new Intl.DateTimeFormat('en-CA', { + timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit', + }).format(target); + const timeText = new Intl.DateTimeFormat('en-GB', { + timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }).format(target); + return { + bizDate: dateText, + dateKey: dateText.replace(/\D/g, ''), + timeKey: timeText.replace(/\D/g, ''), + }; +} + +function resolveBaseUrl() { + const raw = normalizeText( + process.env.DAILY_INVENTORY_SCREENSHOT_BASE_URL + || process.env.PUBLIC_SITE_URL + || DEFAULT_BASE_URL, + 500 + ).replace(/\/+$/, ''); + return raw || DEFAULT_BASE_URL; +} + +function resolvePageUrl() { + return `${resolveBaseUrl()}${PAGE_PATH}`; +} + +/** + * 签发截图专用短时效令牌。 + * 只包含页面鉴权必需的最小声明,不含任何用户凭据或密钥。 + */ +function signScreenshotToken() { + const secret = process.env.CUBEJS_SECRET; + if (!secret) throw new Error('缺少 CUBEJS_SECRET,无法签发截图文身份令牌'); + return jwt.sign({ ...SCREENSHOT_SERVICE_USER }, secret, { expiresIn: '15m' }); +} + +/** 截图存放的临时文件(不落业务目录,上传后立即删除) */ +function buildTempFilePath(bizDate, dateKey, timeKey) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily-inventory-shot-')); + return { + dir, + filePath: path.join(dir, `daily-inventory-${dateKey}-${timeKey}.png`), + objectKey: `daily-inventory/${bizDate}/daily-inventory-${dateKey}-${timeKey}.png`, + }; +} + +async function launchBrowser() { + let playwright; + try { + playwright = require('playwright'); + } catch (_error) { + throw new Error('未安装 playwright,无法执行页面截图'); + } + try { + return await playwright.chromium.launch({ + args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + }); + } catch (error) { + const detail = normalizeText(error?.message, 300); + throw new Error(`浏览器启动失败(服务器需先执行 npx playwright install chromium):${detail}`); + } +} + +/** 等到数据真正渲染完成:有数据行、无加载中、无错误提示 */ +async function waitForPageReady(page) { + await page.waitForSelector('.daily-inventory-table .ant-table-tbody tr', { + timeout: PAGE_READY_TIMEOUT_MS, + state: 'attached', + }); + + await page.waitForFunction( + () => { + const spinning = document.querySelector('.daily-inventory-page .ant-spin-spinning'); + const skeleton = document.querySelector('.ant-skeleton'); + const rows = document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr'); + return !spinning && !skeleton && rows.length > 0; + }, + { timeout: PAGE_READY_TIMEOUT_MS, polling: 500 } + ); + + const errorText = await page.evaluate(() => { + const node = document.querySelector('.daily-inventory-page .ant-alert-error'); + return node ? node.textContent || '' : ''; + }); + if (errorText) { + throw new Error(`页面返回错误提示,放弃截图:${normalizeText(errorText, 200)}`); + } + + const rowCount = await page.evaluate( + () => document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr').length + ); + if (!rowCount) throw new Error('页面未渲染出任何数据行,放弃截图'); + return rowCount; +} + +/** + * 执行一次截图并上传到默认对象存储桶 + * @returns {Promise<{url:string, key:string, bizDate:string, size:number, rowCount:number, takenAt:string}>} + */ +async function captureAndUploadDailyInventory(options = {}) { + const now = options.now ? new Date(options.now) : new Date(); + const { bizDate, dateKey, timeKey } = chinaTimeParts(now); + const token = signScreenshotToken(); + const pageUrl = resolvePageUrl(); + + const browser = await launchBrowser(); + const { dir, filePath, objectKey } = buildTempFilePath(bizDate, dateKey, timeKey); + let rowCount = 0; + + try { + const context = await browser.newContext({ + viewport: { width: 1680, height: 1050 }, + deviceScaleFactor: 1, + timezoneId: TIMEZONE, + locale: 'zh-CN', + }); + // 令牌只注入浏览器 localStorage,URL 与日志里都不出现 + await context.addInitScript((value) => { + try { + window.localStorage.setItem('token', value); + } catch (_error) { + // 忽略:首个 document 可能还不允许访问 localStorage + } + }, token); + + const page = await context.newPage(); + page.on('pageerror', () => {}); + await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: PAGE_READY_TIMEOUT_MS }); + rowCount = await waitForPageReady(page); + + // 展开内部滚动容器,保证整页截图包含完整数据区 + await page.addStyleTag({ content: EXPAND_CSS }); + await page.waitForTimeout(500); + await page.screenshot({ path: filePath, fullPage: true }); + + const stats = await fs.promises.stat(filePath); + if (!stats.size) throw new Error('截图文件为空'); + + const uploaded = await uploadFileToDefaultS3(filePath, { + key: objectKey, + mimeType: 'image/png', + folder: 'daily-inventory', + extension: 'png', + }); + + return { + url: uploaded.url, + key: uploaded.key, + bizDate, + size: stats.size, + rowCount, + takenAt: now.toISOString(), + }; + } finally { + await browser.close().catch(() => {}); + fs.rm(dir, { recursive: true, force: true }, () => {}); + } +} + +module.exports = { + DEFAULT_BASE_URL, + PAGE_PATH, + PAGE_READY_TIMEOUT_MS, + TIMEZONE, + captureAndUploadDailyInventory, + chinaTimeParts, + resolveBaseUrl, + resolvePageUrl, + signScreenshotToken, +}; diff --git a/backend/test/dailyInventoryNotification.test.js b/backend/test/dailyInventoryNotification.test.js new file mode 100644 index 0000000..8225110 --- /dev/null +++ b/backend/test/dailyInventoryNotification.test.js @@ -0,0 +1,165 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DEFAULT_NOTIFY_TIME, + MAX_RECIPIENTS, + buildIdempotencyKey, + normalizeFlag, + normalizeNotifyTime, + normalizeRecipients, +} = require('../src/services/dailyInventoryNotificationSettings'); +const { + ACTION_TITLE, + NOTIFICATION_TITLE, + buildNotificationContent, + minutesOfDay, + resolveBizDate, + withRetry, +} = require('../src/jobs/dailyInventoryNotificationJob'); +const { chinaTimeParts } = require('../src/services/dailyInventoryScreenshot'); + +// ── 收件人名单规范化 ──────────────────────────────────────────── +test('normalizeRecipients 去空值、去空白、保持顺序', () => { + assert.deepEqual(normalizeRecipients(['张三', ' ', '李四', '', null, undefined]), ['张三', '李四']); +}); + +test('normalizeRecipients 去重且忽略首尾空格', () => { + assert.deepEqual(normalizeRecipients([' 张三 ', '张三', '李四', ' 李四']), ['张三', '李四']); +}); + +test('normalizeRecipients 支持逗号/中文逗号/分号/换行分隔的字符串', () => { + assert.deepEqual( + normalizeRecipients('张三,李四,王五;赵六\n孙七'), + ['张三', '李四', '王五', '赵六', '孙七'] + ); +}); + +test('normalizeRecipients 对非法输入返回空数组', () => { + assert.deepEqual(normalizeRecipients(null), []); + assert.deepEqual(normalizeRecipients(undefined), []); + assert.deepEqual(normalizeRecipients(12345), []); +}); + +test('normalizeRecipients 超量时截断到上限', () => { + const list = Array.from({ length: MAX_RECIPIENTS + 5 }, (_, i) => `用户${i}`); + const result = normalizeRecipients(list); + assert.equal(result.length, MAX_RECIPIENTS); + assert.equal(result[0], '用户0'); +}); + +// ── 通知时间校验 ──────────────────────────────────────────────── +test('normalizeNotifyTime 接受合法 HH:mm 并补零', () => { + assert.equal(normalizeNotifyTime('1:05'), '01:05'); + assert.equal(normalizeNotifyTime('23:59'), '23:59'); + assert.equal(normalizeNotifyTime('00:00'), '00:00'); +}); + +test('normalizeNotifyTime 支持 HH:mm:ss 并截断到分钟', () => { + assert.equal(normalizeNotifyTime('01:00:30'), '01:00'); +}); + +test('normalizeNotifyTime 非法值回退默认 01:00', () => { + assert.equal(normalizeNotifyTime('24:00'), DEFAULT_NOTIFY_TIME); + assert.equal(normalizeNotifyTime('12:60'), DEFAULT_NOTIFY_TIME); + assert.equal(normalizeNotifyTime('abc'), DEFAULT_NOTIFY_TIME); + assert.equal(normalizeNotifyTime(''), DEFAULT_NOTIFY_TIME); + assert.equal(normalizeNotifyTime(null), DEFAULT_NOTIFY_TIME); +}); + +test('normalizeFlag 只接受明确的布尔语义,未知值回退默认', () => { + assert.equal(normalizeFlag('true', false), true); + assert.equal(normalizeFlag('off', true), false); + assert.equal(normalizeFlag('maybe', true), true); + assert.equal(normalizeFlag(null, false), false); +}); + +// ── 幂等键 ────────────────────────────────────────────────────── +test('buildIdempotencyKey 正式推送按业务日期+收件人稳定生成', () => { + const a = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' }); + const b = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' }); + const c = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '李四', mode: 'scheduled' }); + assert.equal(a, b); + assert.notEqual(a, c); +}); + +test('buildIdempotencyKey 测试推送带随机数,不占用正式幂等记录', () => { + const formal = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' }); + const t1 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n1' }); + const t2 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n2' }); + assert.notEqual(t1, t2); + assert.notEqual(t1, formal); +}); + +// ── 通知正文 ──────────────────────────────────────────────────── +test('buildNotificationContent 包含数据日期、生成时间、图片地址与页面链接', () => { + const content = buildNotificationContent({ + bizDate: '2026-09-25', + takenAt: '2026/09/25 01:00:00', + imageUrl: 'https://cdn.example.com/daily-inventory/2026-09-25/daily-inventory-20260925-010000.png', + pageUrl: 'https://pddg.jiansuntuzhi.cn/planning/operation/daily-inventory', + }); + assert.match(content, /2026-09-25/); + assert.match(content, /2026\/09\/25 01:00:00/); + assert.match(content, /https:\/\/cdn\.example\.com\//); + assert.match(content, new RegExp(ACTION_TITLE)); + assert.equal(NOTIFICATION_TITLE, '每日进销存数据'); +}); + +// ── 业务日期与调度时刻 ────────────────────────────────────────── +test('resolveBizDate 取北京时间昨日(与页面 T-1 口径一致)', () => { + assert.equal(resolveBizDate(new Date('2026-09-25T01:00:00+08:00')), '2026-09-24'); + assert.equal(resolveBizDate(new Date('2026-09-25T23:30:00+08:00')), '2026-09-24'); +}); + +test('minutesOfDay 正确换算,非法值返回 null', () => { + assert.equal(minutesOfDay('00:00'), 0); + assert.equal(minutesOfDay('01:00'), 60); + assert.equal(minutesOfDay('23:59'), 1439); + assert.equal(minutesOfDay('24:00'), null); + assert.equal(minutesOfDay('abc'), null); + assert.equal(minutesOfDay(null), null); +}); + +test('chinaTimeParts 生成对象键所需的日期与时间片段', () => { + const parts = chinaTimeParts(new Date('2026-09-25T01:00:00+08:00')); + assert.equal(parts.bizDate, '2026-09-25'); + assert.equal(parts.dateKey, '20260925'); + assert.equal(parts.timeKey, '010000'); + assert.equal( + `daily-inventory/${parts.bizDate}/daily-inventory-${parts.dateKey}-${parts.timeKey}.png`, + 'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png' + ); +}); + +// ── 重试与退避 ────────────────────────────────────────────────── +test('withRetry 第二次成功后返回结果', async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls += 1; + if (calls < 2) throw new Error('临时失败'); + return 'ok'; + }, + { label: '单元测试', attempts: 3, baseDelayMs: 1 } + ); + assert.equal(result, 'ok'); + assert.equal(calls, 2); +}); + +test('withRetry 超过次数后抛出最后一次错误', async () => { + let calls = 0; + await assert.rejects( + withRetry( + async () => { + calls += 1; + throw new Error(`失败${calls}`); + }, + { label: '单元测试', attempts: 3, baseDelayMs: 1 } + ), + /失败3/ + ); + assert.equal(calls, 3); +}); diff --git a/frontend/src/api/dailyInventoryNotification.js b/frontend/src/api/dailyInventoryNotification.js new file mode 100644 index 0000000..52c8779 --- /dev/null +++ b/frontend/src/api/dailyInventoryNotification.js @@ -0,0 +1,17 @@ +import request from './request'; + +// 每日进销存 · 定时截图通知(管理员专用) +// 后端只返回「是否已配置」与脱敏预览,不返回任何密钥 + +export const getDailyInventoryNotificationConfig = () => + request.get('/api/daily-inventory-notification/config'); + +export const saveDailyInventoryNotificationConfig = (payload) => + request.put('/api/daily-inventory-notification/config', payload); + +/** 立即测试:真实截图 + 上传 + 通知一次,不影响正式定时任务的幂等记录 */ +export const runDailyInventoryNotificationTest = () => + request.post('/api/daily-inventory-notification/test', {}, { timeout: 300000 }); + +export const getDailyInventoryNotificationRuns = (limit = 10) => + request.get('/api/daily-inventory-notification/runs', { params: { limit } }); diff --git a/frontend/src/pages/DailyInventory/index.jsx b/frontend/src/pages/DailyInventory/index.jsx index f04ab26..5c68eed 100644 --- a/frontend/src/pages/DailyInventory/index.jsx +++ b/frontend/src/pages/DailyInventory/index.jsx @@ -1,8 +1,16 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd'; -import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'; +import { + Alert, Button, Input, Modal, Space, Spin, Switch, Table, Tag, TimePicker, Tooltip, Typography, +} from 'antd'; +import { BellOutlined, InfoCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; import { SycmPage } from '../../components/Common'; import { getDailyInventory } from '../../api/dailyInventory'; +import { + getDailyInventoryNotificationConfig, + runDailyInventoryNotificationTest, + saveDailyInventoryNotificationConfig, +} from '../../api/dailyInventoryNotification.js'; import { DAILY_INVENTORY_COLUMNS, COLUMN_SOURCE, @@ -12,6 +20,34 @@ import './style.css'; const { Title, Text } = Typography; +const TIME_FORMAT = 'HH:mm'; + +/** 当前登录用户是否为管理员(决定「通知设置」入口是否可见) */ +function readIsAdmin() { + try { + const stored = JSON.parse(localStorage.getItem('auth_user') || 'null'); + if (stored?.role) return stored.role === 'admin'; + const token = localStorage.getItem('token') || ''; + const payload = JSON.parse(window.atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + return payload?.role === 'admin'; + } catch { + return false; + } +} + +/** 收件人名单规范化:去空、去重、限长(后端会再校验一次) */ +function normalizeRecipientList(list = []) { + const seen = new Set(); + const result = []; + for (const raw of list) { + const name = String(raw ?? '').trim().slice(0, 50); + if (!name || seen.has(name)) continue; + seen.add(name); + result.push(name); + } + return result; +} + const TipTitle = ({ tip, children }) => ( {children} @@ -45,6 +81,17 @@ const DailyInventory = () => { const [data, setData] = useState(null); const [helpOpen, setHelpOpen] = useState(false); + // ── 定时截图通知设置 ── + const isAdmin = useMemo(readIsAdmin, []); + const [notifyOpen, setNotifyOpen] = useState(false); + const [notifyLoading, setNotifyLoading] = useState(false); + const [notifySaving, setNotifySaving] = useState(false); + const [notifyTesting, setNotifyTesting] = useState(false); + const [notifyConfig, setNotifyConfig] = useState(null); + const [notifyResult, setNotifyResult] = useState(null); + const [recipientDraft, setRecipientDraft] = useState(''); + const [notifyForm, setNotifyForm] = useState({ enabled: false, notifyTime: '01:00', recipients: [] }); + const load = useCallback(async (force = false) => { setLoading(true); setError(''); @@ -98,6 +145,98 @@ const DailyInventory = () => { const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]); + // ── 通知设置:读取 / 保存 / 立即测试 ── + const applyNotifyConfig = useCallback((config = {}) => { + setNotifyConfig(config); + setNotifyForm({ + enabled: config.enabled === true, + notifyTime: config.notifyTime || '01:00', + recipients: normalizeRecipientList(config.recipients || []), + }); + }, []); + + const loadNotifyConfig = useCallback(async () => { + setNotifyLoading(true); + try { + const res = await getDailyInventoryNotificationConfig(); + applyNotifyConfig(res?.data ?? {}); + } catch (err) { + setNotifyConfig({ loadError: err?.response?.data?.error || err?.message || '加载通知设置失败' }); + } finally { + setNotifyLoading(false); + } + }, [applyNotifyConfig]); + + useEffect(() => { + if (notifyOpen) { + setNotifyResult(null); + loadNotifyConfig(); + } + }, [notifyOpen, loadNotifyConfig]); + + const saveNotifySettings = useCallback(async () => { + setNotifySaving(true); + setNotifyResult(null); + try { + const res = await saveDailyInventoryNotificationConfig({ + enabled: notifyForm.enabled, + notifyTime: notifyForm.notifyTime, + recipients: normalizeRecipientList(notifyForm.recipients), + }); + applyNotifyConfig(res?.data?.admin ?? res?.data ?? {}); + setNotifyResult({ type: 'success', text: '通知设置已保存' }); + } catch (err) { + setNotifyResult({ + type: 'error', + text: err?.response?.data?.error || err?.message || '保存通知设置失败', + }); + } finally { + setNotifySaving(false); + } + }, [notifyForm, applyNotifyConfig]); + + const runNotifyTest = useCallback(async () => { + setNotifyTesting(true); + setNotifyResult(null); + try { + const res = await runDailyInventoryNotificationTest(); + const result = res?.data ?? {}; + if (result.success) { + setNotifyResult({ + type: 'success', + text: `测试完成:截图已上传,成功通知 ${(result.recipients || []).filter((r) => r.status === 'sent').length} 人`, + url: result.screenshotUrl, + }); + } else { + setNotifyResult({ type: 'error', text: `测试未成功:${result.error || result.reason || '未知原因'}` }); + } + loadNotifyConfig(); + } catch (err) { + setNotifyResult({ + type: 'error', + text: err?.response?.data?.error || err?.message || '执行测试失败', + }); + } finally { + setNotifyTesting(false); + } + }, [loadNotifyConfig]); + + const addRecipients = useCallback(() => { + const incoming = normalizeRecipientList(String(recipientDraft || '').split(/[,,;;\n]+/)); + if (!incoming.length) return; + setNotifyForm((old) => ({ + ...old, + recipients: normalizeRecipientList([...(old.recipients || []), ...incoming]), + })); + setRecipientDraft(''); + }, [recipientDraft]); + + const removeRecipient = useCallback((name) => { + setNotifyForm((old) => ({ + ...old, + recipients: (old.recipients || []).filter((item) => item !== name), + })); + }, []); return ( @@ -129,6 +268,9 @@ const DailyInventory = () => { ) : null} + {isAdmin ? ( + + ) : null} @@ -172,6 +314,159 @@ const DailyInventory = () => { 共 {items.length} 个仓库;蓝色底纹列为运行时实时取数,灰色列为暂无单据来源,其余为本地快照表取值。 + setNotifyOpen(false)} + width={720} + destroyOnHidden + styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }} + footer={( + + + + + + + + )} + > + + {notifyConfig?.loadError ? ( + + ) : null} + + {notifyConfig?.blockingReasons?.length ? ( + {notifyConfig.blockingReasons.join(';')}} + /> + ) : null} + +
+
+ 启用每日截图通知 + setNotifyForm((old) => ({ ...old, enabled: checked }))} + /> +
+ +
+ 通知时间 + setNotifyForm((old) => ({ + ...old, + notifyTime: value ? value.format(TIME_FORMAT) : '01:00', + }))} + style={{ width: 140 }} + /> + ({notifyConfig?.timezone || 'Asia/Shanghai'},默认 01:00) +
+ +
+ 通知用户 + setRecipientDraft(e.target.value)} + onPressEnter={addRecipients} + style={{ width: 320 }} + /> + +
+ +
+ {notifyForm.recipients.length ? ( + notifyForm.recipients.map((name) => ( + removeRecipient(name)}>{name} + )) + ) : ( + 尚未添加通知用户 + )} +
+ + 自动去除空值与重复姓名,最多 {notifyConfig?.maxRecipients || 20} 人。 + +
+ + {notifyResult ? ( + {notifyResult.url} + ) : null} + /> + ) : null} + + {notifyConfig && !notifyConfig.loadError ? ( +
+
+ 统一通知中心: + + {notifyConfig.notificationCenter?.configured ? '已配置' : '未配置'} + + {notifyConfig.notificationCenter?.baseUrl ? ( + {` ${notifyConfig.notificationCenter.baseUrl}`} + ) : null} +
+
+ 对象存储: + + {notifyConfig.objectStorage?.configured ? '已配置' : '未配置'} + + {notifyConfig.objectStorage?.bucket ? ( + {` 桶 ${notifyConfig.objectStorage.bucket}`} + ) : null} +
+
+ 上次执行: + {notifyConfig.lastRunAt ? new Date(notifyConfig.lastRunAt).toLocaleString('zh-CN') : '—'} + {notifyConfig.lastRunSuccess === null ? '' : ( + + {` ${notifyConfig.lastRunSuccess ? '成功' : '失败'}`} + + )} +
+
+ ) : null} + + {notifyConfig?.runs?.length ? ( +
+ 最近执行记录 + {notifyConfig.runs.slice(0, 5).map((run, index) => ( +
+ {new Date(run.runAt).toLocaleString('zh-CN')} + + {run.success ? '成功' : `失败(${run.error || run.reason || '未知原因'})`} + + {run.screenshotUrl ? ( + 截图 + ) : null} +
+ ))} +
+ ) : null} +
+
+ Date: Thu, 24 Sep 2026 11:59:36 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E5=9C=B0=E5=9D=80=E4=B8=8E=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E9=93=BE=E6=8E=A5=EF=BC=8C=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E8=A1=A5=E5=85=85=20Chromium=20=E4=B8=8E?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E5=AD=97=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 截图默认站点改为生产主站 ck.yydd.ai(原默认值指向无关域名) - 通知正文补充「页面地址」,便于在通知中心直接打开 - 服务身份增加 daily_inventory 页面权限声明 - Dockerfile 安装 Chromium 与 fonts-noto-cjk,否则容器内无法截图且中文显示为方块 --- backend/Dockerfile.production | 9 +++++++-- backend/src/jobs/dailyInventoryNotificationJob.js | 1 + backend/src/services/dailyInventoryScreenshot.js | 3 ++- backend/test/dailyInventoryNotification.test.js | 3 ++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/Dockerfile.production b/backend/Dockerfile.production index dfa160a..b587bb1 100644 --- a/backend/Dockerfile.production +++ b/backend/Dockerfile.production @@ -3,12 +3,17 @@ FROM node:22-bookworm-slim ENV NODE_ENV=production WORKDIR /app +# 每日进销存定时截图需要 Chromium 与中文字体: +# · fonts-noto-cjk —— 否则截图里所有中文都会变成方块 +# · playwright install --with-deps chromium —— 默认镜像不带浏览器 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ + && apt-get install -y --no-install-recommends ca-certificates fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json ./ -RUN npm ci --omit=dev --no-audit --no-fund +RUN npm ci --omit=dev --no-audit --no-fund \ + && npx playwright install --with-deps chromium \ + && rm -rf /var/lib/apt/lists/* /root/.npm/_cacache COPY . . diff --git a/backend/src/jobs/dailyInventoryNotificationJob.js b/backend/src/jobs/dailyInventoryNotificationJob.js index f397eca..22eac9a 100644 --- a/backend/src/jobs/dailyInventoryNotificationJob.js +++ b/backend/src/jobs/dailyInventoryNotificationJob.js @@ -129,6 +129,7 @@ function buildNotificationContent({ bizDate, takenAt, imageUrl, pageUrl }) { `**数据日期:** ${bizDate}`, `**截图生成时间:** ${takenAt}(${TIMEZONE})`, `**图片地址:** ${imageUrl}`, + `**页面地址:** ${pageUrl}`, '', `点击右下角「${ACTION_TITLE}」可直达每日进销存页面。`, ].join('\n'); diff --git a/backend/src/services/dailyInventoryScreenshot.js b/backend/src/services/dailyInventoryScreenshot.js index e82bdd3..594ade8 100644 --- a/backend/src/services/dailyInventoryScreenshot.js +++ b/backend/src/services/dailyInventoryScreenshot.js @@ -18,7 +18,8 @@ const { uploadFileToDefaultS3 } = require('./objectStorage'); const TIMEZONE = 'Asia/Shanghai'; const PAGE_PATH = '/planning/operation/daily-inventory'; -const DEFAULT_BASE_URL = 'https://pddg.jiansuntuzhi.cn'; +// 生产主站(见《服务器部署规范》):可用 DAILY_INVENTORY_SCREENSHOT_BASE_URL / PUBLIC_SITE_URL 覆盖 +const DEFAULT_BASE_URL = 'https://ck.yydd.ai'; // 页面首次加载要扫订单明细,约 15~20 秒,等待上限给到 3 分钟 const PAGE_READY_TIMEOUT_MS = 3 * 60 * 1000; const SCREENSHOT_SERVICE_USER = Object.freeze({ diff --git a/backend/test/dailyInventoryNotification.test.js b/backend/test/dailyInventoryNotification.test.js index 8225110..941c05c 100644 --- a/backend/test/dailyInventoryNotification.test.js +++ b/backend/test/dailyInventoryNotification.test.js @@ -99,11 +99,12 @@ test('buildNotificationContent 包含数据日期、生成时间、图片地址 bizDate: '2026-09-25', takenAt: '2026/09/25 01:00:00', imageUrl: 'https://cdn.example.com/daily-inventory/2026-09-25/daily-inventory-20260925-010000.png', - pageUrl: 'https://pddg.jiansuntuzhi.cn/planning/operation/daily-inventory', + pageUrl: 'https://ck.yydd.ai/planning/operation/daily-inventory', }); assert.match(content, /2026-09-25/); assert.match(content, /2026\/09\/25 01:00:00/); assert.match(content, /https:\/\/cdn\.example\.com\//); + assert.match(content, /https:\/\/ck\.yydd\.ai\/planning\/operation\/daily-inventory/); assert.match(content, new RegExp(ACTION_TITLE)); assert.equal(NOTIFICATION_TITLE, '每日进销存数据'); }); -- 2.47.2