定时任务在凌晨 01:00 推送的是「昨日」数据,但截图对象键原先直接取执行时刻的北京日期, 导致归档目录与通知里的业务日期不一致(daily-inventory/2026-09-26/... 装的是 09-25 的数据)。 现改为由定时任务显式传入业务日期,目录与文件名统一用业务日期、时间仍用执行时刻: daily-inventory/2026-09-25/daily-inventory-20260925-010000.png 另补充验收要求 8 的测试: · buildScreenshotNames 归档命名(含业务日期与执行日期不同的场景) · markNotified / hasNotified 幂等:同业务日同用户只成功一次,跨日期互不影响 · appendRunRecord 保留执行时间、截图地址、通知对象与失败原因
308 lines
11 KiB
JavaScript
308 lines
11 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 crypto = require('crypto');
|
||
const jwt = require('jsonwebtoken');
|
||
const bcrypt = require('bcryptjs');
|
||
const database = require('../config/database');
|
||
const { uploadFileToDefaultS3 } = require('./objectStorage');
|
||
|
||
const TIMEZONE = 'Asia/Shanghai';
|
||
const PAGE_PATH = '/planning/operation/daily-inventory';
|
||
// 生产主站(见《服务器部署规范》):可用 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;
|
||
/**
|
||
* 截图专用服务账号
|
||
*
|
||
* 页面启动时会调用 /api/auth/verify,该接口要求令牌的 sub 必须是系统库 users 表中
|
||
* 真实且 active 的用户;仅签名合法但用户不存在的令牌会被判定为未登录并跳转登录页,
|
||
* 截图也就拿不到任何数据。因此这里不使用凭空捏造的身份,而是在系统库维护一个
|
||
* 仅供服务端使用的专用账号,令牌以该账号的 id 作为 sub。
|
||
*
|
||
* 该账号只以服务端签发的短时效令牌方式使用:密码哈希是随机值且永不对外,
|
||
* 任何人无法通过密码登录;删除该行即可彻底回收。
|
||
*/
|
||
const SERVICE_USERNAME = String(
|
||
process.env.DAILY_INVENTORY_SCREENSHOT_USERNAME || 'daily_inventory_screenshot_bot'
|
||
).trim().slice(0, 50) || 'daily_inventory_screenshot_bot';
|
||
const SERVICE_NAME = '每日进销存截图服务';
|
||
const SERVICE_EMAIL = `${SERVICE_USERNAME}@service.local`;
|
||
|
||
let cachedServiceUserId = null;
|
||
|
||
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}`;
|
||
}
|
||
|
||
/** 清空服务账号 id 缓存(账号被重建或测试时刷新用) */
|
||
function resetScreenshotServiceUserCache() {
|
||
cachedServiceUserId = null;
|
||
}
|
||
|
||
/** 生成一个无法用于登录的密码哈希(随机串无人知晓,仅满足字段非空约束) */
|
||
function buildUnusablePasswordHash() {
|
||
return bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10);
|
||
}
|
||
|
||
/**
|
||
* 确保系统库存在截图专用服务账号,并返回其 id。
|
||
* 只维护 auth_source='service' 的本服务账号,不触碰任何人工账号。
|
||
*/
|
||
async function ensureScreenshotServiceUser() {
|
||
if (cachedServiceUserId) return cachedServiceUserId;
|
||
|
||
const rows = await database.local.query(
|
||
'SELECT id, role, status FROM users WHERE username = ? LIMIT 1',
|
||
[SERVICE_USERNAME]
|
||
);
|
||
let user = rows[0];
|
||
|
||
if (!user) {
|
||
const created = await database.local.insert('users', {
|
||
username: SERVICE_USERNAME,
|
||
password_hash: await buildUnusablePasswordHash(),
|
||
name: SERVICE_NAME,
|
||
email: SERVICE_EMAIL,
|
||
role: 'admin',
|
||
status: 'active',
|
||
auth_source: 'service',
|
||
});
|
||
user = { id: created.insertId, role: 'admin', status: 'active' };
|
||
} else if (user.status !== 'active' || user.role !== 'admin') {
|
||
await database.local.query(
|
||
"UPDATE users SET role = 'admin', status = 'active', auth_source = 'service' WHERE id = ? AND auth_source = 'service'",
|
||
[user.id]
|
||
);
|
||
}
|
||
|
||
cachedServiceUserId = Number(user.id);
|
||
return cachedServiceUserId;
|
||
}
|
||
|
||
/**
|
||
* 签发截图专用短时效令牌。
|
||
* sub 为系统库中的专用服务账号 id,令牌只写入浏览器 localStorage,
|
||
* 不会出现在截图地址、日志或接口响应里,也不含任何密钥。
|
||
*/
|
||
async function signScreenshotToken() {
|
||
const secret = process.env.CUBEJS_SECRET;
|
||
if (!secret) throw new Error('缺少 CUBEJS_SECRET,无法签发截图身份令牌');
|
||
const userId = await ensureScreenshotServiceUser();
|
||
return jwt.sign(
|
||
{
|
||
sub: String(userId),
|
||
username: SERVICE_USERNAME,
|
||
name: SERVICE_NAME,
|
||
role: 'admin',
|
||
authSource: 'service',
|
||
allowedPageKeys: [],
|
||
},
|
||
secret,
|
||
{ expiresIn: '15m' }
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 截图文件名与对象键。
|
||
* 目录与文件名里的日期都用**业务日期**,时间用执行时刻,便于按数据日期归档:
|
||
* 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 {
|
||
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 {
|
||
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 { 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, 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,
|
||
SERVICE_NAME,
|
||
SERVICE_USERNAME,
|
||
TIMEZONE,
|
||
buildScreenshotNames,
|
||
captureAndUploadDailyInventory,
|
||
chinaTimeParts,
|
||
ensureScreenshotServiceUser,
|
||
resetScreenshotServiceUserCache,
|
||
resolveBaseUrl,
|
||
resolvePageUrl,
|
||
signScreenshotToken,
|
||
};
|