Merge pull request 'fix: 每日进销存截图改用系统库专用服务账号身份' (#42) from codex/daily-inventory-notify-identity-0927 into main
This commit is contained in:
commit
485097354b
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user