From 117d59c9f0dbff89955dd7f7cc8b668284533256 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Sun, 27 Sep 2026 14:53:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=AF=8F=E6=97=A5=E8=BF=9B=E9=94=80?= =?UTF-8?q?=E5=AD=98=E6=88=AA=E5=9B=BE=E6=8C=89=E4=B8=9A=E5=8A=A1=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E5=BD=92=E6=A1=A3=E5=B9=B6=E8=A1=A5=E5=85=85=E5=B9=82?= =?UTF-8?q?=E7=AD=89/=E6=89=A7=E8=A1=8C=E8=AE=B0=E5=BD=95=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定时任务在凌晨 01:00 推送的是「昨日」数据,但截图对象键原先直接取执行时刻的北京日期, 导致归档目录与通知里的业务日期不一致(daily-inventory/2026-09-26/... 装的是 09-25 的数据)。 现改为由定时任务显式传入业务日期,目录与文件名统一用业务日期、时间仍用执行时刻: daily-inventory/2026-09-25/daily-inventory-20260925-010000.png 另补充验收要求 8 的测试: · buildScreenshotNames 归档命名(含业务日期与执行日期不同的场景) · markNotified / hasNotified 幂等:同业务日同用户只成功一次,跨日期互不影响 · appendRunRecord 保留执行时间、截图地址、通知对象与失败原因 --- .../src/jobs/dailyInventoryNotificationJob.js | 3 +- .../src/services/dailyInventoryScreenshot.js | 31 ++++-- .../test/dailyInventoryNotification.test.js | 94 ++++++++++++++++++- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/backend/src/jobs/dailyInventoryNotificationJob.js b/backend/src/jobs/dailyInventoryNotificationJob.js index 22eac9a..dc57b78 100644 --- a/backend/src/jobs/dailyInventoryNotificationJob.js +++ b/backend/src/jobs/dailyInventoryNotificationJob.js @@ -189,7 +189,8 @@ async function runDailyInventoryNotificationJob(options = {}) { let shot; try { shot = await withRetry( - () => captureAndUploadDailyInventory({ now }), + // 对象键按业务日期归档:daily-inventory/2026-09-25/daily-inventory-20260925-010000.png + () => captureAndUploadDailyInventory({ now, bizDate }), { label: '截图并上传' } ); } catch (error) { diff --git a/backend/src/services/dailyInventoryScreenshot.js b/backend/src/services/dailyInventoryScreenshot.js index 85623b3..a4b7db5 100644 --- a/backend/src/services/dailyInventoryScreenshot.js +++ b/backend/src/services/dailyInventoryScreenshot.js @@ -152,16 +152,28 @@ async function signScreenshotToken() { ); } -/** 截图存放的临时文件(不落业务目录,上传后立即删除) */ -function buildTempFilePath(bizDate, dateKey, timeKey) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily-inventory-shot-')); +/** + * 截图文件名与对象键。 + * 目录与文件名里的日期都用**业务日期**,时间用执行时刻,便于按数据日期归档: + * daily-inventory/2026-09-25/daily-inventory-20260925-010000.png + */ +function buildScreenshotNames({ bizDate, timeKey }) { + const safeDate = normalizeText(bizDate, 10); + const dateKey = safeDate.replace(/\D/g, ''); + const fileName = `daily-inventory-${dateKey}-${timeKey}.png`; return { - dir, - filePath: path.join(dir, `daily-inventory-${dateKey}-${timeKey}.png`), - objectKey: `daily-inventory/${bizDate}/daily-inventory-${dateKey}-${timeKey}.png`, + fileName, + objectKey: `daily-inventory/${safeDate}/${fileName}`, }; } +/** 截图存放的临时文件(不落业务目录,上传后立即删除) */ +function buildTempFilePath({ bizDate, timeKey }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily-inventory-shot-')); + const { fileName, objectKey } = buildScreenshotNames({ bizDate, timeKey }); + return { dir, filePath: path.join(dir, fileName), objectKey }; +} + async function launchBrowser() { let playwright; try { @@ -217,12 +229,14 @@ async function waitForPageReady(page) { */ async function captureAndUploadDailyInventory(options = {}) { const now = options.now ? new Date(options.now) : new Date(); - const { bizDate, dateKey, timeKey } = chinaTimeParts(now); + const { timeKey } = chinaTimeParts(now); + // 业务日期由调用方(定时任务)给出;未指定时按北京时间当天兜底 + const bizDate = normalizeText(options.bizDate, 10) || chinaTimeParts(now).bizDate; const token = await signScreenshotToken(); const pageUrl = resolvePageUrl(); const browser = await launchBrowser(); - const { dir, filePath, objectKey } = buildTempFilePath(bizDate, dateKey, timeKey); + const { dir, filePath, objectKey } = buildTempFilePath({ bizDate, timeKey }); let rowCount = 0; try { @@ -282,6 +296,7 @@ module.exports = { SERVICE_NAME, SERVICE_USERNAME, TIMEZONE, + buildScreenshotNames, captureAndUploadDailyInventory, chinaTimeParts, ensureScreenshotServiceUser, diff --git a/backend/test/dailyInventoryNotification.test.js b/backend/test/dailyInventoryNotification.test.js index 852cdca..ac35b08 100644 --- a/backend/test/dailyInventoryNotification.test.js +++ b/backend/test/dailyInventoryNotification.test.js @@ -19,7 +19,10 @@ const { resolveBizDate, withRetry, } = require('../src/jobs/dailyInventoryNotificationJob'); -const { chinaTimeParts } = require('../src/services/dailyInventoryScreenshot'); +const { + buildScreenshotNames, + chinaTimeParts, +} = require('../src/services/dailyInventoryScreenshot'); // ── 收件人名单规范化 ──────────────────────────────────────────── test('normalizeRecipients 去空值、去空白、保持顺序', () => { @@ -135,6 +138,95 @@ test('chinaTimeParts 生成对象键所需的日期与时间片段', () => { ); }); +test('buildScreenshotNames 用业务日期归档目录与文件名,时间取执行时刻', () => { + const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' }); + assert.equal(names.fileName, 'daily-inventory-20260925-010000.png'); + assert.equal( + names.objectKey, + 'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png' + ); +}); + +test('buildScreenshotNames 业务日期与执行日期不同时以业务日期为准', () => { + // 凌晨 01:00 推送的是「昨日」数据,归档必须落在业务日而不是执行日 + const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' }); + assert.ok(!names.objectKey.includes('2026-09-26')); + assert.ok(names.objectKey.startsWith('daily-inventory/2026-09-25/')); +}); + +test('buildScreenshotNames 业务日期缺失时回退空目录占位而不抛错', () => { + const names = buildScreenshotNames({ bizDate: '', timeKey: '010000' }); + assert.equal(names.fileName, 'daily-inventory--010000.png'); +}); + +// ── 幂等记录:同一业务日期 + 同一用户只成功通知一次 ──────────── +async function withStubbedState(runner) { + const database = require('../src/config/database'); + const settings = require('../src/services/dailyInventoryNotificationSettings'); + const original = database.local; + const store = new Map(); + + database.local = { + async query(sql, params) { + const text = String(sql); + if (text.startsWith('SELECT config_value')) { + const value = store.get(params[0]); + return value === undefined ? [] : [{ configValue: value }]; + } + if (text.startsWith('SELECT id FROM system_config')) { + const value = store.get(params[0]); + return value === undefined ? [] : [{ id: 1 }]; + } + // UPDATE system_config SET config_value = ? ... + store.set(params[params.length - 1], params[0]); + return { affectedRows: 1 }; + }, + async insert(_table, data) { + store.set(data.config_key, data.config_value); + return { insertId: 1 }; + }, + }; + + try { + return await runner(settings); + } finally { + database.local = original; + } +} + +test('markNotified 后同一业务日期同一用户命中幂等,跨日期互不影响', async () => { + await withStubbedState(async (settings) => { + assert.equal(await settings.hasNotified('2026-09-25', '苏适'), false); + await settings.markNotified('2026-09-25', '苏适', { deliveryId: 'd-1' }); + assert.equal(await settings.hasNotified('2026-09-25', '苏适'), true); + // 同一天其他用户不受影响 + assert.equal(await settings.hasNotified('2026-09-25', '李四'), false); + // 同一用户换一天应当重新通知 + assert.equal(await settings.hasNotified('2026-09-26', '苏适'), false); + }); +}); + +test('appendRunRecord 保留执行时间、截图地址、通知对象与失败原因', async () => { + await withStubbedState(async (settings) => { + await settings.appendRunRecord({ + runAt: '2026-09-25T01:00:00.000Z', + bizDate: '2026-09-25', + mode: 'scheduled', + success: false, + error: '所有收件人均通知失败', + screenshotUrl: 'https://scqny.jiansun.vip/daily-inventory/2026-09-25/x.png', + recipients: [{ recipient: '苏适', status: 'failed', error: '通知中心 502' }], + }); + const runs = await settings.listRunRecords(5); + assert.equal(runs.length, 1); + assert.equal(runs[0].bizDate, '2026-09-25'); + assert.equal(runs[0].success, false); + assert.equal(runs[0].error, '所有收件人均通知失败'); + assert.equal(runs[0].recipients[0].recipient, '苏适'); + assert.ok(runs[0].screenshotUrl.startsWith('https://')); + }); +}); + // ── 重试与退避 ────────────────────────────────────────────────── test('withRetry 第二次成功后返回结果', async () => { let calls = 0;