设置(仅管理员) - 可配置是否启用、通知时间(默认 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 项)
217 lines
7.4 KiB
JavaScript
217 lines
7.4 KiB
JavaScript
'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,
|
||
};
|