diff --git a/backend/src/services/dailyInventoryScreenshot.js b/backend/src/services/dailyInventoryScreenshot.js index 594ade8..85623b3 100644 --- a/backend/src/services/dailyInventoryScreenshot.js +++ b/backend/src/services/dailyInventoryScreenshot.js @@ -13,7 +13,10 @@ 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'; @@ -22,14 +25,24 @@ const PAGE_PATH = '/planning/operation/daily-inventory'; 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({ - sub: 'daily-inventory-screenshot-bot', - username: 'daily_inventory_screenshot_bot', - name: '每日进销存截图服务', - role: 'admin', - authSource: 'service', - allowedPageKeys: [], -}); +/** + * 截图专用服务账号 + * + * 页面启动时会调用 /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; } @@ -71,14 +84,72 @@ 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, + * 不会出现在截图地址、日志或接口响应里,也不含任何密钥。 */ -function signScreenshotToken() { +async function signScreenshotToken() { const secret = process.env.CUBEJS_SECRET; - if (!secret) throw new Error('缺少 CUBEJS_SECRET,无法签发截图文身份令牌'); - return jwt.sign({ ...SCREENSHOT_SERVICE_USER }, secret, { expiresIn: '15m' }); + 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' } + ); } /** 截图存放的临时文件(不落业务目录,上传后立即删除) */ @@ -147,7 +218,7 @@ 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 token = signScreenshotToken(); + const token = await signScreenshotToken(); const pageUrl = resolvePageUrl(); const browser = await launchBrowser(); @@ -208,9 +279,13 @@ module.exports = { DEFAULT_BASE_URL, PAGE_PATH, PAGE_READY_TIMEOUT_MS, + SERVICE_NAME, + SERVICE_USERNAME, TIMEZONE, captureAndUploadDailyInventory, chinaTimeParts, + ensureScreenshotServiceUser, + resetScreenshotServiceUserCache, resolveBaseUrl, resolvePageUrl, signScreenshotToken, diff --git a/backend/test/dailyInventoryNotification.test.js b/backend/test/dailyInventoryNotification.test.js index 941c05c..852cdca 100644 --- a/backend/test/dailyInventoryNotification.test.js +++ b/backend/test/dailyInventoryNotification.test.js @@ -164,3 +164,78 @@ test('withRetry 超过次数后抛出最后一次错误', async () => { ); assert.equal(calls, 3); }); + +// ── 截图身份:必须使用系统库中真实存在的服务账号 ────────────── +test('ensureScreenshotServiceUser 账号不存在时创建,存在时复用 id', async () => { + const database = require('../src/config/database'); + const screenshot = require('../src/services/dailyInventoryScreenshot'); + const original = database.local; + const calls = { select: 0, insert: 0, update: 0 }; + let existing = null; + + database.local = { + async query(sql, params) { + if (String(sql).startsWith('SELECT id, role, status FROM users')) { + calls.select += 1; + return existing ? [existing] : []; + } + calls.update += 1; + return { affectedRows: 1 }; + }, + async insert(_table, data) { + calls.insert += 1; + assert.equal(data.role, 'admin'); + assert.equal(data.status, 'active'); + assert.equal(data.auth_source, 'service'); + assert.ok(String(data.password_hash || '').length > 20, '密码哈希不能为空'); + existing = { id: 4242, role: data.role, status: data.status }; + return { insertId: 4242 }; + }, + }; + + try { + screenshot.resetScreenshotServiceUserCache(); + const created = await screenshot.ensureScreenshotServiceUser(); + assert.equal(created, 4242); + assert.equal(calls.insert, 1); + assert.equal(calls.select, 1); + + // 第二次调用命中缓存,不再查库 + assert.equal(await screenshot.ensureScreenshotServiceUser(), 4242); + assert.equal(calls.select, 1); + } finally { + database.local = original; + screenshot.resetScreenshotServiceUserCache(); + } +}); + +test('signScreenshotToken 的 sub 指向服务账号 id 且通过签名校验', async () => { + const database = require('../src/config/database'); + const screenshot = require('../src/services/dailyInventoryScreenshot'); + const jwt = require('jsonwebtoken'); + const original = database.local; + const originalSecret = process.env.CUBEJS_SECRET; + process.env.CUBEJS_SECRET = originalSecret || 'test-secret-for-unit'; + + database.local = { + async query() { + return [{ id: 99, role: 'admin', status: 'active' }]; + }, + async insert() { + throw new Error('不应在账号已存在时插入'); + }, + }; + + try { + screenshot.resetScreenshotServiceUserCache(); + const token = await screenshot.signScreenshotToken(); + const payload = jwt.verify(token, process.env.CUBEJS_SECRET || 'test-secret'); + assert.equal(payload.sub, '99'); + assert.equal(payload.role, 'admin'); + assert.equal(payload.authSource, 'service'); + } finally { + database.local = original; + process.env.CUBEJS_SECRET = originalSecret; + screenshot.resetScreenshotServiceUserCache(); + } +});