Files
prop-ai-hr/scripts/verify-broadcast-m0-local.mjs
T

464 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* M0 "银城大喇叭" 写入验收。
*
* 默认仅允许对本机回环地址写入:发布一条唯一测试消息,验证员工端的可见、已读和
* 消息追问链路,再撤回该消息。脚本始终要求调用方提供已授权的短期管理员和员工令牌,
* 不会读取服务端验证码、密码或会话数据。远端写入还必须同时提供命令行和环境变量的
* 明确授权。
*/
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = parseArgs(process.argv.slice(2));
const baseUrl = trimTrailingSlash(args.get('base-url') || process.env.AIHR_BASE_URL || 'https://wygj-api.localhost');
const url = new URL(baseUrl);
const isLocal = url.hostname === 'localhost'
|| url.hostname === '127.0.0.1'
|| url.hostname === '::1'
|| url.hostname.endsWith('.localhost');
const allowRemoteWrite = args.has('allow-remote-write') && process.env.AIHR_M0_ALLOW_REMOTE_WRITE === 'true';
const configuredTenantId = String(args.get('tenant-id') || process.env.AIHR_M0_TENANT_ID || '').trim();
const reportPath = args.get('report') ? path.resolve(root, args.get('report')) : '';
const mobileClientId = process.env.AIHR_M0_EMPLOYEE_CLIENT_ID || '428a8310cd442757ae699df5d894f051';
const adminClientId = process.env.AIHR_M0_ADMIN_CLIENT_ID || 'e5cd7e4891bf95d1d19206ce24a7b32e';
const prepareLocalQueryFixture = args.has('prepare-local-query-fixture');
const runId = `${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${crypto.randomBytes(4).toString('hex')}`;
const title = `M0 自动验收 ${runId}`;
const content = `这是仅用于本机 M0 自动化验收的临时公司消息(${runId})。请以正式通知为准。`;
const withdrawReason = `M0 自动化验收清理 ${runId}`;
const checks = [];
let admin;
let employee;
let tenantId = '';
let messageId;
let withdrawn = false;
let failure;
let cleanupFailure;
const queryFixture = {
enabled: false,
appId: null,
knowledgeId: null,
appCreated: false,
appBindingCreated: false,
employeeGrantCreated: false
};
if (url.protocol === 'https:' && url.hostname.endsWith('.localhost') && !process.env.NODE_EXTRA_CA_CERTS) {
// ponytail: portless local CA is not visible to Node fetch in every shell session.
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
function parseArgs(values) {
return new Map(values.map((value) => {
const [key, ...rest] = value.replace(/^--/, '').split('=');
return [key, rest.length ? rest.join('=') : 'true'];
}));
}
function trimTrailingSlash(value) {
return String(value).replace(/\/+$/, '');
}
async function request(method, endpoint, { token, clientId, body } = {}) {
const headers = {
clientid: clientId || mobileClientId,
'Content-Language': 'zh_CN'
};
if (token) headers.Authorization = `Bearer ${token}`;
let requestBody;
if (body !== undefined) {
headers['Content-Type'] = 'application/json;charset=utf-8';
requestBody = JSON.stringify(body);
}
let response;
try {
response = await fetch(`${baseUrl}${endpoint}`, {
method,
headers,
body: requestBody,
signal: AbortSignal.timeout(30_000)
});
} catch (error) {
throw new Error(`${method} ${endpoint} 请求失败:${error instanceof Error ? error.message : String(error)}`);
}
const text = await response.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
throw new Error(`${method} ${endpoint} 返回了无法解析的响应(HTTP ${response.status})`);
}
return { status: response.status, payload };
}
function businessCode(result) {
return Number(result?.payload?.code);
}
function requireSuccess(result, label) {
if (result.status < 200 || result.status >= 300 || businessCode(result) !== 200) {
const message = String(result.payload?.msg || `HTTP ${result.status}`);
throw new Error(`${label}失败:${message}`);
}
return result.payload.data;
}
function requireUnavailable(result, label) {
if (businessCode(result) !== 404) {
throw new Error(`${label}应不可用,实际业务码为 ${Number.isFinite(businessCode(result)) ? businessCode(result) : `HTTP ${result.status}`}`);
}
}
function record(name, details = {}) {
checks.push({ name, status: 'PASS', ...details });
console.log(`PASS ${name}`);
}
function requireValue(value, label) {
if (value === null || value === undefined || value === '') throw new Error(`${label}缺失`);
return value;
}
function docker(container, command) {
try {
return execFileSync('docker', ['exec', container, ...command], { encoding: 'utf8' }).trim();
} catch {
throw new Error('本机消息追问临时授权需要可用的 wygj-mysql Docker 容器;也可省略 --prepare-local-query-fixture,直接验证现有授权配置');
}
}
function localMysql(sql) {
return docker('wygj-mysql', [
'mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-Nse', sql
]);
}
function sqlLiteral(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function prepareQueryFixture() {
if (!prepareLocalQueryFixture) return;
if (!isLocal) throw new Error('--prepare-local-query-fixture 只允许用于本机环境');
if (!tenantId) throw new Error('准备消息追问测试数据前必须先确认租户');
queryFixture.enabled = true;
const tenant = sqlLiteral(tenantId);
const existingApp = localMysql(`select concat(id, '|', status) from aihr_knowledge_app
where tenant_id = ${tenant} and auth_type = 'SESSION' and internal_client_key = 'app' limit 1`).trim();
const [existingAppIdText, existingAppStatus] = existingApp.split('|');
const existingAppId = Number(existingAppIdText);
let appId = Number.isSafeInteger(existingAppId) && existingAppId > 0 ? existingAppId : null;
if (appId && existingAppStatus !== 'ACTIVE') {
throw new Error('本机已有移动端知识应用但未启用;请先启用该应用,或在隔离环境中执行验收');
}
if (!appId) {
const appCode = `m0qa_${runId}`.slice(0, 64);
localMysql(`insert into aihr_knowledge_app
(tenant_id, app_code, app_name, auth_type, internal_client_key, status, rate_limit_per_minute, create_time, update_time)
values (${tenant}, ${sqlLiteral(appCode)}, 'M0 本机验收临时应用', 'SESSION', 'app', 'ACTIVE', 60, now(), now())`);
appId = Number(localMysql(`select id from aihr_knowledge_app where tenant_id = ${tenant}
and app_code = ${sqlLiteral(appCode)} limit 1`).trim());
if (!Number.isSafeInteger(appId) || appId < 1) throw new Error('无法创建本机消息追问临时应用');
queryFixture.appCreated = true;
}
queryFixture.appId = appId;
const existingKnowledgeId = Number(localMysql(`select id from aihr_knowledge_info
where tenant_id = ${tenant} and status = 'ACTIVE' order by id limit 1`).trim());
if (!Number.isSafeInteger(existingKnowledgeId) || existingKnowledgeId < 1) {
throw new Error('本机没有启用的知识空间,无法准备消息追问测试数据');
}
queryFixture.knowledgeId = existingKnowledgeId;
const bindingCount = Number(localMysql(`select count(*) from aihr_knowledge_app_space
where tenant_id = ${tenant} and app_id = ${appId} and knowledge_id = ${existingKnowledgeId}`).trim());
if (bindingCount === 0) {
localMysql(`insert into aihr_knowledge_app_space (tenant_id, app_id, knowledge_id, create_time)
values (${tenant}, ${appId}, ${existingKnowledgeId}, now())`);
queryFixture.appBindingCreated = true;
}
const grantCount = Number(localMysql(`select count(*) from aihr_knowledge_space_grant
where tenant_id = ${tenant} and knowledge_id = ${existingKnowledgeId}
and principal_type = 'ROLE' and principal_value = 'employee'
and permission in ('READ', 'MANAGE') and status = 'ACTIVE'`).trim());
if (grantCount === 0) {
localMysql(`insert into aihr_knowledge_space_grant
(tenant_id, knowledge_id, principal_type, principal_value, permission, status, create_time, update_time)
values (${tenant}, ${existingKnowledgeId}, 'ROLE', 'employee', 'READ', 'ACTIVE', now(), now())`);
queryFixture.employeeGrantCreated = true;
}
record('本机消息追问临时授权已准备');
}
function cleanupQueryFixture() {
if (!queryFixture.enabled || !isLocal || !tenantId) return;
const tenant = sqlLiteral(tenantId);
try {
if (queryFixture.employeeGrantCreated && Number.isSafeInteger(queryFixture.knowledgeId)) {
localMysql(`delete from aihr_knowledge_space_grant where tenant_id = ${tenant}
and knowledge_id = ${queryFixture.knowledgeId} and principal_type = 'ROLE'
and principal_value = 'employee' and permission = 'READ'`);
}
if (queryFixture.appBindingCreated && Number.isSafeInteger(queryFixture.appId) && Number.isSafeInteger(queryFixture.knowledgeId)) {
localMysql(`delete from aihr_knowledge_app_space where tenant_id = ${tenant}
and app_id = ${queryFixture.appId} and knowledge_id = ${queryFixture.knowledgeId}`);
}
if (queryFixture.appCreated && Number.isSafeInteger(queryFixture.appId)) {
localMysql(`delete from aihr_knowledge_app where tenant_id = ${tenant} and id = ${queryFixture.appId}`);
}
console.log('CLEANUP 已恢复本机消息追问临时授权');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
cleanupFailure ||= new Error(`本机消息追问临时授权清理失败:${message}`);
console.error(`CLEANUP_FAILED ${message}`);
}
}
function protectWriteScope() {
if (prepareLocalQueryFixture && !isLocal) {
throw new Error('--prepare-local-query-fixture 只允许用于本机环境');
}
if (!isLocal && !allowRemoteWrite) {
throw new Error('拒绝对非本机环境写入。若确需在专用测试租户执行,请同时传入 --allow-remote-write 与 AIHR_M0_ALLOW_REMOTE_WRITE=true');
}
if (!isLocal && !configuredTenantId) throw new Error('非本机验收必须明确传入 --tenant-id=<专用测试租户>');
if (!process.env.AIHR_M0_ADMIN_TOKEN || !process.env.AIHR_M0_EMPLOYEE_TOKEN) {
throw new Error('M0 验收必须提供 AIHR_M0_ADMIN_TOKEN 和 AIHR_M0_EMPLOYEE_TOKEN;脚本不会读取验证码、密码或会话数据');
}
}
function compactResult() {
return {
schemaVersion: 1,
suite: 'broadcast-m0-acceptance',
outcome: failure ? 'FAIL' : 'PASS',
baseUrl,
runId,
tenantId: tenantId || null,
messageId: messageId || null,
withdrawn,
localQueryFixture: queryFixture.enabled ? {
prepared: true,
appCreated: queryFixture.appCreated,
appBindingCreated: queryFixture.appBindingCreated,
employeeGrantCreated: queryFixture.employeeGrantCreated
} : null,
checks,
failedAt: failure?.message || null
};
}
function writeReport() {
if (!reportPath) return;
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(compactResult(), null, 2)}\n`, 'utf8');
console.log(`报告已写入 ${reportPath}`);
}
async function cleanup() {
if (!messageId || withdrawn || !admin || !tenantId) return;
try {
const result = await request('POST', `/api/aihr/broadcast/messages/${messageId}/withdraw`, {
token: admin.token,
clientId: admin.clientId,
body: { reason: withdrawReason, expectedTenantId: tenantId }
});
requireSuccess(result, '失败后的测试消息撤回');
withdrawn = true;
console.log('CLEANUP 已撤回临时测试消息');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
cleanupFailure ||= new Error(`临时测试消息撤回失败:${message}`);
console.error(`CLEANUP_FAILED ${message}`);
}
}
async function main() {
protectWriteScope();
admin = { token: process.env.AIHR_M0_ADMIN_TOKEN, clientId: adminClientId };
employee = { token: process.env.AIHR_M0_EMPLOYEE_TOKEN, clientId: mobileClientId };
record('受控管理员和员工令牌已提供');
const adminList = requireSuccess(await request('GET', '/api/aihr/broadcast/admin/messages?pageNum=1&pageSize=1&status=all', {
token: admin.token,
clientId: admin.clientId
}), '读取管理员消息范围');
tenantId = String(requireValue(adminList?.tenantId, '管理员生效租户')).trim();
if (configuredTenantId && tenantId !== configuredTenantId) {
throw new Error(`管理员生效租户与预期不一致:实际 ${tenantId},预期 ${configuredTenantId}`);
}
record('管理员租户范围已确认', { tenantId });
prepareQueryFixture();
const employeePublish = await request('POST', '/api/aihr/broadcast/messages', {
token: employee.token,
clientId: employee.clientId,
body: { requestId: `forbidden-${runId}`, title, content, expectedTenantId: tenantId }
});
if (employeePublish.status < 400 && businessCode(employeePublish) === 200) {
throw new Error('员工令牌意外拥有公司消息发布权限');
}
record('员工不能发布公司消息');
const unreadBefore = requireSuccess(await request('GET', '/api/aihr/broadcast/unread-count', {
token: employee.token,
clientId: employee.clientId
}), '读取发布前未读数');
const unreadBeforeCount = Number(unreadBefore?.unreadCount);
if (!Number.isInteger(unreadBeforeCount) || unreadBeforeCount < 0) throw new Error('发布前未读数非法');
record('发布前员工未读数可读取');
const publishRequest = {
requestId: `m0-${runId}`,
title,
content,
expectedTenantId: tenantId
};
const published = requireSuccess(await request('POST', '/api/aihr/broadcast/messages', {
token: admin.token,
clientId: admin.clientId,
body: publishRequest
}), '发布测试消息');
messageId = Number(requireValue(published?.id, '发布消息编号'));
if (!Number.isSafeInteger(messageId) || messageId < 1 || Number(published?.version) !== 1) {
throw new Error('发布响应未返回预期的消息编号或 v1 版本');
}
record('管理员发布消息', { version: 1 });
const replay = requireSuccess(await request('POST', '/api/aihr/broadcast/messages', {
token: admin.token,
clientId: admin.clientId,
body: publishRequest
}), '重放同一发布请求');
if (Number(replay?.id) !== messageId || Number(replay?.version) !== 1) {
throw new Error('相同 requestId 未返回原消息,发布幂等性失效');
}
record('发布重放保持幂等');
const employeeList = requireSuccess(await request('GET', '/api/aihr/broadcast/messages?pageNum=1&pageSize=100', {
token: employee.token,
clientId: employee.clientId
}), '读取员工消息列表');
const listed = Array.isArray(employeeList?.rows) ? employeeList.rows.find((row) => Number(row?.id) === messageId) : null;
if (!listed || listed.read !== false || listed.title !== title) throw new Error('新发布消息未以未读状态出现在员工列表');
record('员工列表可见新消息且为未读');
const detailBeforeRead = requireSuccess(await request('GET', `/api/aihr/broadcast/messages/${messageId}`, {
token: employee.token,
clientId: employee.clientId
}), '读取员工消息详情');
if (detailBeforeRead?.title !== title || detailBeforeRead?.content !== content || detailBeforeRead?.read !== false) {
throw new Error('员工详情与发布内容或未读状态不一致');
}
record('员工详情可读取');
requireSuccess(await request('POST', `/api/aihr/broadcast/messages/${messageId}/read`, {
token: employee.token,
clientId: employee.clientId
}), '员工首次标记已读');
requireSuccess(await request('POST', `/api/aihr/broadcast/messages/${messageId}/read`, {
token: employee.token,
clientId: employee.clientId
}), '员工重复标记已读');
const detailAfterRead = requireSuccess(await request('GET', `/api/aihr/broadcast/messages/${messageId}`, {
token: employee.token,
clientId: employee.clientId
}), '复核员工已读状态');
if (detailAfterRead?.read !== true) throw new Error('员工首次已读记录未生效');
const unreadAfterRead = requireSuccess(await request('GET', '/api/aihr/broadcast/unread-count', {
token: employee.token,
clientId: employee.clientId
}), '读取已读后的未读数');
if (Number(unreadAfterRead?.unreadCount) !== unreadBeforeCount) {
throw new Error('重复已读改变了未读数,已读幂等性失效');
}
record('员工已读审计保持幂等');
const question = requireSuccess(await request('POST', '/api/knowledge/query', {
token: employee.token,
clientId: employee.clientId,
body: {
queryText: '请概述这条消息的要求,不要把它解释成已向我个人分配任务。',
category: 'sop',
position: '客服管家',
source: 'm0_broadcast_acceptance',
limit: 5,
broadcastMessageId: messageId
}
}), '围绕公司消息追问');
const questionJson = JSON.stringify(question);
const hasBroadcastCitation = Array.isArray(question?.citations)
&& question.citations.some((citation) => citation?.sourceType === 'BCAST');
if (Number(question?.broadcastContext?.messageId) !== messageId || !hasBroadcastCitation || questionJson.includes(content)) {
throw new Error('消息追问未保留可信上下文、未给出 BCAST 引用,或意外序列化了消息正文');
}
record('员工可围绕消息追问且不泄露正文');
requireSuccess(await request('POST', `/api/aihr/broadcast/messages/${messageId}/withdraw`, {
token: admin.token,
clientId: admin.clientId,
body: { reason: withdrawReason, expectedTenantId: tenantId }
}), '撤回测试消息');
withdrawn = true;
const withdrawnList = requireSuccess(await request('GET', '/api/aihr/broadcast/admin/messages?pageNum=1&pageSize=100&status=WITHDRAWN', {
token: admin.token,
clientId: admin.clientId
}), '读取撤回审计');
const withdrawnRow = Array.isArray(withdrawnList?.rows)
? withdrawnList.rows.find((row) => Number(row?.id) === messageId)
: null;
if (!withdrawnRow || withdrawnRow.status !== 'WITHDRAWN' || withdrawnRow.withdrawReason !== withdrawReason) {
throw new Error('撤回记录或首次撤回原因未保留');
}
record('撤回保留审计记录');
const employeeListAfterWithdraw = requireSuccess(await request('GET', '/api/aihr/broadcast/messages?pageNum=1&pageSize=100', {
token: employee.token,
clientId: employee.clientId
}), '撤回后读取员工消息列表');
if (Array.isArray(employeeListAfterWithdraw?.rows)
&& employeeListAfterWithdraw.rows.some((row) => Number(row?.id) === messageId)) {
throw new Error('已撤回消息仍出现在员工列表');
}
requireUnavailable(await request('GET', `/api/aihr/broadcast/messages/${messageId}`, {
token: employee.token,
clientId: employee.clientId
}), '撤回后员工详情');
requireUnavailable(await request('POST', '/api/knowledge/query', {
token: employee.token,
clientId: employee.clientId,
body: {
queryText: '这条消息还有效吗?',
category: 'sop',
position: '客服管家',
source: 'm0_broadcast_acceptance',
limit: 5,
broadcastMessageId: messageId
}
}), '撤回后消息追问');
record('撤回后员工不可见且不可追问');
}
try {
await main();
} catch (error) {
failure = error instanceof Error ? error : new Error(String(error));
} finally {
await cleanup();
cleanupQueryFixture();
if (!failure && cleanupFailure) failure = cleanupFailure;
writeReport();
}
if (failure) {
console.error(`M0_BROADCAST_ACCEPTANCE FAIL ${failure.message}`);
process.exitCode = 1;
} else {
console.log(`M0_BROADCAST_ACCEPTANCE PASS ${checks.length}/${checks.length}`);
}