464 lines
20 KiB
JavaScript
464 lines
20 KiB
JavaScript
#!/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}`);
|
||
}
|