'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, };