#!/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}`); }