模拟器增加事件历史/异常日志/完整汇总导出功能,添加15分钟采集测试脚本

- DeviceState: 新增EventRecord类和RecordEvent方法,记录程序切换/清零/断电/开机事件
- LogRecorder: 新增ErrorRecord类和RecordError方法,记录异常状态
- SimulatorServer: 新增/admin/api/event-history、/admin/api/full-summary、/admin/api/error-log三个API端点
- SimulatorServer: 异常分支(http500/timeout/empty/malformed)自动记录异常日志
- 新增15分钟Playwright采集测试脚本(6个异常阶段+数据对比验证+自动报告生成)
main
haoliang 4 days ago
parent cb504215b9
commit 36cb666af3

@ -0,0 +1,816 @@
/**
* 156+
*
*
* 0:
* 1: 5
* 2: HTTP 5002
* 3: 2
* 4: 2
* 5: 2
* 6: 2
* : vs
* : docs/test-reports/
*
* :
* 1. MariaDB cnc_business + cnc_log
* 2. CncSimulator
* 3. CncCollector
*
* : npm install @playwright/test mysql2
* : npx playwright test collector-15min.spec.ts --reporter=list --timeout=0
*/
import { test, expect, request } from '@playwright/test';
import mysql from 'mysql2/promise';
import { execSync, spawn, ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
// ============================================================
// 常量与配置
// ============================================================
const API_BASE = 'http://localhost:5800';
const SIM_GATEWAY = 'http://localhost:9001';
const API_KEY = 'collector_api_key_2026';
const HEADERS = { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' };
const DB_BUSINESS = {
host: 'localhost', user: 'root', password: 'root',
database: 'cnc_business', charset: 'utf8mb4'
};
const DB_LOG = {
host: 'localhost', user: 'root', password: 'root',
database: 'cnc_log', charset: 'utf8mb4'
};
/** 每个阶段的持续时间(毫秒) */
const PHASE_DURATIONS = {
normal1: 5 * 60 * 1000, // 阶段1: 正常采集5分钟
http500: 2 * 60 * 1000, // 阶段2: HTTP 500异常2分钟
timeout: 2 * 60 * 1000, // 阶段3: 超时异常2分钟
empty: 2 * 60 * 1000, // 阶段4: 空数据2分钟
refuse: 2 * 60 * 1000, // 阶段5: 拒绝连接2分钟
normal2: 2 * 60 * 1000, // 阶段6: 恢复正常2分钟
};
/** 测试报告输出路径 */
const REPORT_DIR = path.resolve(__dirname, '../../../docs/test-reports');
// ============================================================
// 数据库辅助函数
// ============================================================
async function queryBusiness(sql: string, params?: any[]) {
const conn = await mysql.createConnection(DB_BUSINESS);
try {
const [rows] = await conn.query(sql, params);
return rows as any[];
} finally {
await conn.end();
}
}
async function queryLog(sql: string, params?: any[]) {
const conn = await mysql.createConnection(DB_LOG);
try {
const [rows] = await conn.query(sql, params);
return rows as any[];
} finally {
await conn.end();
}
}
async function executeBusiness(sql: string, params?: any[]) {
const conn = await mysql.createConnection(DB_BUSINESS);
try {
await conn.execute(sql, params);
} finally {
await conn.end();
}
}
async function executeLog(sql: string, params?: any[]) {
const conn = await mysql.createConnection(DB_LOG);
try {
await conn.execute(sql, params);
} finally {
await conn.end();
}
}
/** 辅助调用采集服务管理API */
async function collectorApi(method: string, path: string) {
const ctx = await request.newContext();
const opts: any = { headers: HEADERS };
if (method === 'GET') return ctx.get(`${API_BASE}${path}`, opts);
return ctx.post(`${API_BASE}${path}`, opts);
}
/** 辅助:等待指定毫秒 */
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
/** 辅助调用模拟器管理API */
async function simApi(port: number, path: string, body?: any) {
const ctx = await request.newContext();
if (body) {
return ctx.post(`http://localhost:${port}${path}`, { data: body, headers: { 'Content-Type': 'application/json' } });
}
return ctx.get(`http://localhost:${port}${path}`);
}
/** 辅助调用模拟器网关API */
async function gatewayApi(path: string, body?: any) {
const ctx = await request.newContext();
if (body) {
return ctx.post(`${SIM_GATEWAY}${path}`, { data: body, headers: { 'Content-Type': 'application/json' } });
}
return ctx.get(`${SIM_GATEWAY}${path}`);
}
/** 日志输出带时间戳 */
function log(msg: string) {
console.log(`[${new Date().toLocaleTimeString('zh-CN')}] ${msg}`);
}
// ============================================================
// 全局变量
// ============================================================
let simulationPort = 0;
let testStartTime: Date;
let phaseTimestamps: Record<string, Date> = {};
/** 每阶段结束时采集模拟器快照 */
let phaseSnapshots: Record<string, any> = {};
// ============================================================
// 全局串行配置
// ============================================================
test.describe.configure({ mode: 'serial' });
// ============================================================
// 阶段0: 初始化
// ============================================================
test.describe('15分钟自动化采集测试', () => {
test('阶段0: 初始化 — 清空数据并启动服务', async () => {
test.setTimeout(120000); // 2分钟超时
testStartTime = new Date();
log('\n===== 15分钟自动化采集测试开始 =====');
log(`测试开始时间: ${testStartTime.toLocaleString('zh-CN')}`);
// 1. 清空采集数据
log('[0/6] 清空旧采集数据...');
await executeBusiness('TRUNCATE TABLE cnc_collect_record');
await executeBusiness('TRUNCATE TABLE cnc_production_segment');
await executeLog('TRUNCATE TABLE log_collect_raw');
log(' ✓ 已清空 cnc_collect_record, cnc_production_segment, log_collect_raw');
// 2. 确认采集地址配置
log('[1/6] 确认采集地址配置...');
const addrs = await queryBusiness('SELECT id, url, is_enabled, collect_interval FROM cnc_collect_address WHERE id = 1');
expect(addrs.length).toBe(1);
log(` ✓ 采集地址: id=${addrs[0].id}, url=${addrs[0].url}, interval=${addrs[0].collect_interval}s`);
// 3. 确认机床数据
log('[2/6] 确认机床数据...');
const machines = await queryBusiness(
'SELECT id, device_code, name, collect_address_id, is_enabled FROM cnc_machine WHERE collect_address_id = 1 AND is_enabled = 1'
);
expect(machines.length).toBeGreaterThanOrEqual(10);
log(` ✓ 机床数量: ${machines.length}采集地址1下的启用机床`);
// 4. 通过模拟器网关启动地址模拟
log('[3/6] 启动模拟器...');
const startResp = await gatewayApi('/admin/api/start-address', {
dbAddressId: 1,
interval: 10,
deviceCodes: machines.map((m: any) => m.device_code)
});
expect(startResp.ok()).toBeTruthy();
const startResult = await startResp.json() as any;
simulationPort = startResult.port || 9001;
log(` ✓ 模拟器已启动 → 端口 ${simulationPort}`);
// 等待模拟器数据就绪
await sleep(3000);
const dataResp = await gatewayApi(`/admin/api/status`);
if (dataResp.ok()) {
const status = await dataResp.json() as any;
log(` ✓ 模拟器状态: ${status.totalDevices}台设备, 在线${status.onlineDevices}`);
}
// 5. 更新采集地址URL指向模拟端口
await executeBusiness(
'UPDATE cnc_collect_address SET url = ?, is_enabled = 1 WHERE id = 1',
[`http://localhost:${simulationPort}/`]
);
log(` ✓ 更新采集地址URL → http://localhost:${simulationPort}/`);
// 6. 启动采集服务
log('[4/6] 启动采集服务...');
await collectorApi('POST', '/api/collector/stop');
await sleep(1000);
await collectorApi('POST', '/api/collector/refresh');
await sleep(500);
await collectorApi('POST', '/api/collector/start');
log(' ✓ 采集服务已启动');
// 7. 等待2个采集周期确认数据流
log('[5/6] 等待数据采集确认...');
await sleep(25000);
// 验证已有数据产生
const records = await queryBusiness(
'SELECT COUNT(*) as cnt FROM cnc_collect_record WHERE collect_time > ?',
[testStartTime]
);
const rawLogs = await queryLog(
'SELECT COUNT(*) as cnt FROM log_collect_raw WHERE request_time > ?',
[testStartTime]
);
log(` ✓ 采集记录数: ${records[0].cnt}, 原始日志数: ${rawLogs[0].cnt}`);
expect(records[0].cnt).toBeGreaterThan(0);
// 8. 保存阶段0快照
phaseTimestamps['init'] = new Date();
const snapshot0 = await gatewayApi(`/admin/api/full-summary`);
if (snapshot0.ok()) phaseSnapshots['init'] = await snapshot0.json();
log('[6/6] 初始化完成开始15分钟采集测试\n');
});
// ============================================================
// 阶段1: 正常采集5分钟
// ============================================================
test('阶段1: 正常采集 — 5分钟建立基准数据', async () => {
test.setTimeout(PHASE_DURATIONS.normal1 + 60000);
log('━━━ 阶段1: 正常采集5分钟 ━━━');
phaseTimestamps['normal1_start'] = new Date();
// 记录开始时的模拟器状态
const statsBefore = await gatewayApi(`/admin/api/stats`);
const statsBeforeData = statsBefore.ok() ? await statsBefore.json() : {};
log(` 开始: 总零件=${statsBeforeData.totalParts || '?'}, 在线设备=${statsBeforeData.onlineDevices || '?'}`);
log(` 等待 ${PHASE_DURATIONS.normal1 / 1000} 秒...`);
// 等待5分钟
await sleep(PHASE_DURATIONS.normal1);
// 记录结束时的状态
phaseTimestamps['normal1_end'] = new Date();
const statsAfter = await gatewayApi(`/admin/api/stats`);
const statsAfterData = statsAfter.ok() ? await statsAfter.json() : {};
// 保存快照
const snapshot = await gatewayApi(`/admin/api/full-summary`);
if (snapshot.ok()) phaseSnapshots['normal1'] = await snapshot.json();
log(` 结束: 总零件=${statsAfterData.totalParts || '?'}, 在线设备=${statsAfterData.onlineDevices || '?'}`);
log(` ✓ 阶段1完成\n`);
});
// ============================================================
// 阶段2: HTTP 500异常2分钟
// ============================================================
test('阶段2: HTTP 500异常 — 2分钟', async () => {
test.setTimeout(PHASE_DURATIONS.http500 + 60000);
log('━━━ 阶段2: HTTP 500异常2分钟 ━━━');
phaseTimestamps['http500_start'] = new Date();
// 设置网络异常: HTTP 500
const setResp = await gatewayApi(`/admin/api/network`, { type: 'http500' });
if (setResp.ok()) {
log(' ✓ 已设置网络异常: HTTP 500');
}
log(` 等待 ${PHASE_DURATIONS.http500 / 1000} 秒...`);
await sleep(PHASE_DURATIONS.http500);
phaseTimestamps['http500_end'] = new Date();
// 保存快照
const snapshot = await gatewayApi(`/admin/api/full-summary`);
if (snapshot.ok()) phaseSnapshots['http500'] = await snapshot.json();
// 恢复网络
await gatewayApi(`/admin/api/network`, { type: 'normal' });
log(' ✓ 已恢复正常网络');
log(` ✓ 阶段2完成\n`);
});
// ============================================================
// 阶段3: 超时异常2分钟
// ============================================================
test('阶段3: 超时异常 — 2分钟', async () => {
test.setTimeout(PHASE_DURATIONS.timeout + 60000);
log('━━━ 阶段3: 超时异常2分钟 ━━━');
phaseTimestamps['timeout_start'] = new Date();
// 设置网络异常: 超时
const setResp = await gatewayApi(`/admin/api/network`, { type: 'timeout' });
if (setResp.ok()) {
log(' ✓ 已设置网络异常: 超时(60秒延迟)');
}
log(` 等待 ${PHASE_DURATIONS.timeout / 1000} 秒...`);
await sleep(PHASE_DURATIONS.timeout);
phaseTimestamps['timeout_end'] = new Date();
// 保存快照
const snapshot = await gatewayApi(`/admin/api/full-summary`);
if (snapshot.ok()) phaseSnapshots['timeout'] = await snapshot.json();
// 恢复网络
await gatewayApi(`/admin/api/network`, { type: 'normal' });
await sleep(2000); // 等待恢复
log(' ✓ 已恢复正常网络');
log(` ✓ 阶段3完成\n`);
});
// ============================================================
// 阶段4: 空数据返回2分钟
// ============================================================
test('阶段4: 空数据返回 — 2分钟', async () => {
test.setTimeout(PHASE_DURATIONS.empty + 60000);
log('━━━ 阶段4: 空数据返回2分钟 ━━━');
phaseTimestamps['empty_start'] = new Date();
// 设置网络异常: 空数据
const setResp = await gatewayApi(`/admin/api/network`, { type: 'empty' });
if (setResp.ok()) {
log(' ✓ 已设置网络异常: 空数据([])');
}
log(` 等待 ${PHASE_DURATIONS.empty / 1000} 秒...`);
await sleep(PHASE_DURATIONS.empty);
phaseTimestamps['empty_end'] = new Date();
// 保存快照
const snapshot = await gatewayApi(`/admin/api/full-summary`);
if (snapshot.ok()) phaseSnapshots['empty'] = await snapshot.json();
// 恢复网络
await gatewayApi(`/admin/api/network`, { type: 'normal' });
await sleep(2000);
log(' ✓ 已恢复正常网络');
log(` ✓ 阶段4完成\n`);
});
// ============================================================
// 阶段5: 拒绝连接2分钟
// ============================================================
test('阶段5: 拒绝连接 — 2分钟', async () => {
test.setTimeout(PHASE_DURATIONS.refuse + 60000);
log('━━━ 阶段5: 拒绝连接2分钟 ━━━');
phaseTimestamps['refuse_start'] = new Date();
// 设置网络异常: 拒绝连接
const setResp = await gatewayApi(`/admin/api/network`, { type: 'refuse' });
if (setResp.ok()) {
log(' ✓ 已设置网络异常: 拒绝连接');
}
log(` 等待 ${PHASE_DURATIONS.refuse / 1000} 秒...`);
await sleep(PHASE_DURATIONS.refuse);
phaseTimestamps['refuse_end'] = new Date();
// 保存快照(此时可能无法访问,用之前的数据)
try {
const snapshot = await gatewayApi(`/admin/api/full-summary`);
if (snapshot.ok()) phaseSnapshots['refuse'] = await snapshot.json();
} catch {
log(' (拒绝连接期间无法获取模拟器快照,跳过)');
}
// 恢复网络
await gatewayApi(`/admin/api/network`, { type: 'normal' });
await sleep(3000); // 等待HttpListener重启
log(' ✓ 已恢复正常网络');
log(` ✓ 阶段5完成\n`);
});
// ============================================================
// 阶段6: 恢复正常采集2分钟
// ============================================================
test('阶段6: 恢复正常采集 — 2分钟', async () => {
test.setTimeout(PHASE_DURATIONS.normal2 + 60000);
log('━━━ 阶段6: 恢复正常采集2分钟 ━━━');
phaseTimestamps['normal2_start'] = new Date();
// 确认网络已恢复
await gatewayApi(`/admin/api/network`, { type: 'normal' });
await sleep(2000);
// 等待新数据产生
const dataCheck = await gatewayApi(`/admin/api/status`);
if (dataCheck.ok()) {
const status = await dataCheck.json() as any;
log(` 当前状态: 在线${status.onlineDevices}台, 运行${status.isRunning}`);
}
log(` 等待 ${PHASE_DURATIONS.normal2 / 1000} 秒...`);
await sleep(PHASE_DURATIONS.normal2);
phaseTimestamps['normal2_end'] = new Date();
// 保存最终快照
const snapshot = await gatewayApi(`/admin/api/full-summary`);
if (snapshot.ok()) phaseSnapshots['normal2'] = await snapshot.json();
// 停止采集服务
await collectorApi('POST', '/api/collector/stop');
log(' ✓ 采集服务已停止触发service_stop结账');
await sleep(3000);
// 停止模拟器
await gatewayApi(`/admin/api/stop-address`, { dbAddressId: 1 });
log(' ✓ 模拟器已停止');
log(` ✓ 阶段6完成\n`);
});
// ============================================================
// 数据对比验证
// ============================================================
test('验证: 数据完整性对比', async () => {
test.setTimeout(60000);
log('━━━ 数据完整性对比验证 ━━━');
// 1. 获取模拟器最终汇总
let simSummary: any = phaseSnapshots['normal2'] || {};
try {
const resp = await gatewayApi(`/admin/api/full-summary`);
if (resp.ok()) simSummary = await resp.json();
} catch { /* 模拟器可能已停止 */ }
log('\n [1/5] 模拟器最终汇总:');
log(` 总设备: ${simSummary.totalDevices || '?'}`);
log(` 总零件: ${simSummary.totalParts || '?'}`);
log(` 异常数: ${simSummary.errorCount || 0}`);
// 2. 查询数据库统计
const dbRecords = await queryBusiness(
'SELECT COUNT(*) as cnt FROM cnc_collect_record WHERE collect_time > ?',
[testStartTime]
);
const dbSegments = await queryBusiness(
'SELECT COUNT(*) as cnt FROM cnc_production_segment WHERE created_at > ?',
[testStartTime]
);
const dbSettled = await queryBusiness(
'SELECT COUNT(*) as cnt FROM cnc_production_segment WHERE is_settled = 1 AND created_at > ?',
[testStartTime]
);
const dbActive = await queryBusiness(
'SELECT COUNT(*) as cnt FROM cnc_production_segment WHERE is_settled = 0 AND created_at > ?',
[testStartTime]
);
const dbRawLogs = await queryLog(
'SELECT COUNT(*) as cnt FROM log_collect_raw WHERE request_time > ?',
[testStartTime]
);
const dbSuccessLogs = await queryLog(
'SELECT COUNT(*) as cnt FROM log_collect_raw WHERE is_success = 1 AND request_time > ?',
[testStartTime]
);
const dbFailLogs = await queryLog(
'SELECT COUNT(*) as cnt FROM log_collect_raw WHERE is_success = 0 AND request_time > ?',
[testStartTime]
);
log('\n [2/5] 数据库统计:');
log(` 采集记录数: ${dbRecords[0].cnt}`);
log(` 产量分段数: ${dbSegments[0].cnt} (已结账=${dbSettled[0].cnt}, 未结账=${dbActive[0].cnt})`);
log(` 原始日志数: ${dbRawLogs[0].cnt} (成功=${dbSuccessLogs[0].cnt}, 失败=${dbFailLogs[0].cnt})`);
// 验证数据存在
expect(dbRecords[0].cnt).toBeGreaterThan(0);
expect(dbRawLogs[0].cnt).toBeGreaterThan(0);
log(' ✓ 采集记录和原始日志均已产生');
// 3. 按设备对比零件数
log('\n [3/5] 按设备零件数对比:');
const simParts = simSummary.devices || [];
let matchCount = 0;
let mismatchCount = 0;
let mismatchDetails: string[] = [];
for (const simDev of simParts) {
const deviceCode = simDev.deviceCode;
const simTotal = simDev.totalParts || 0;
// 查数据库中对应设备的分段总产量
const machine = await queryBusiness(
'SELECT id FROM cnc_machine WHERE device_code = ?',
[deviceCode]
);
if (machine.length === 0) {
log(`${deviceCode}: 数据库中未找到对应机床`);
continue;
}
const segments = await queryBusiness(
'SELECT program_name, quantity, is_settled, close_reason FROM cnc_production_segment WHERE machine_id = ? AND created_at > ?',
[machine[0].id, testStartTime]
);
let dbTotal = 0;
for (const seg of segments) {
dbTotal += Number(seg.quantity) || 0;
}
const diff = Math.abs(simTotal - dbTotal);
const tolerance = Math.max(simTotal * 0.1, 5); // 允许10%或5个的误差
if (diff <= tolerance) {
matchCount++;
if (diff > 0) {
log(`${deviceCode}: 模拟器=${simTotal}, 数据库=${dbTotal} (差${diff}, 在容差内)`);
}
} else {
mismatchCount++;
const detail = `${deviceCode}: 模拟器=${simTotal}, 数据库=${dbTotal} (差${diff})`;
mismatchDetails.push(detail);
log(detail);
}
}
log(`\n 匹配: ${matchCount}台, 不匹配: ${mismatchCount}`);
if (mismatchCount === 0) {
log(' ✓ 所有设备零件数完全匹配!');
} else {
log(` ⚠ 有 ${mismatchCount} 台设备零件数不匹配,详见上方`);
}
// 4. 验证分段结账正确性
log('\n [4/5] 分段结账验证:');
const closeReasons = await queryBusiness(
`SELECT close_reason, COUNT(*) as cnt FROM cnc_production_segment
WHERE created_at > ? GROUP BY close_reason`,
[testStartTime]
);
for (const row of closeReasons) {
log(` ${row.close_reason || 'NULL'}: ${row.cnt}`);
}
// 验证所有分段已结账service_stop触发
const unsettled = await queryBusiness(
'SELECT machine_id, program_name FROM cnc_production_segment WHERE is_settled = 0 AND created_at > ?',
[testStartTime]
);
if (unsettled.length === 0) {
log(' ✓ 所有分段已结账');
} else {
log(` ⚠ 有 ${unsettled.length} 条分段未结账`);
}
// 5. 验证异常期间的数据
log('\n [5/5] 异常期间数据验证:');
// HTTP 500期间应该有失败日志
const http500Logs = await queryLog(
`SELECT COUNT(*) as cnt FROM log_collect_raw
WHERE is_success = 0 AND error_message LIKE '%500%'
AND request_time BETWEEN ? AND ?`,
[phaseTimestamps['http500_start'], phaseTimestamps['http500_end']]
);
log(` HTTP 500期间失败日志: ${http500Logs[0]?.cnt || 0}`);
// 拒绝连接期间应该有失败日志
const refuseLogs = await queryLog(
`SELECT COUNT(*) as cnt FROM log_collect_raw
WHERE is_success = 0 AND error_message LIKE '%连接%'
AND request_time BETWEEN ? AND ?`,
[phaseTimestamps['refuse_start'], phaseTimestamps['refuse_end']]
);
log(` 拒绝连接期间失败日志: ${refuseLogs[0]?.cnt || 0}`);
// 恢复后应有新数据
const recoveryRecords = await queryBusiness(
`SELECT COUNT(*) as cnt FROM cnc_collect_record
WHERE collect_time > ?`,
[phaseTimestamps['normal2_start']]
);
log(` 恢复后新增采集记录: ${recoveryRecords[0].cnt}`);
expect(recoveryRecords[0].cnt).toBeGreaterThan(0);
log(' ✓ 恢复后数据正常产生');
log('\n ✓ 数据完整性对比完成\n');
});
// ============================================================
// 生成测试报告
// ============================================================
test('报告: 生成15分钟采集测试报告', async () => {
test.setTimeout(30000);
// 确保报告目录存在
if (!fs.existsSync(REPORT_DIR)) {
fs.mkdirSync(REPORT_DIR, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
const reportPath = path.join(REPORT_DIR, `${timestamp}-15分钟采集测试报告.md`);
// 收集最终数据
let simSummary: any = phaseSnapshots['normal2'] || {};
try {
const resp = await gatewayApi(`/admin/api/full-summary`);
if (resp.ok()) simSummary = await resp.json();
} catch { /* 模拟器可能已停止 */ }
const eventHistory = await gatewayApi(`/admin/api/event-history`);
const eventHistoryData = eventHistory.ok() ? await eventHistory.json() : [];
const errorLog = await gatewayApi(`/admin/api/error-log`);
const errorLogData = errorLog.ok() ? await errorLog.json() : [];
const dbRecords = await queryBusiness(
'SELECT COUNT(*) as cnt FROM cnc_collect_record WHERE collect_time > ?',
[testStartTime]
);
const dbSegments = await queryBusiness(
'SELECT COUNT(*) as cnt FROM cnc_production_segment WHERE created_at > ?',
[testStartTime]
);
const dbSettled = await queryBusiness(
'SELECT COUNT(*) as cnt FROM cnc_production_segment WHERE is_settled = 1 AND created_at > ?',
[testStartTime]
);
const dbRawLogs = await queryLog(
'SELECT COUNT(*) as cnt FROM log_collect_raw WHERE request_time > ?',
[testStartTime]
);
const dbSuccessLogs = await queryLog(
'SELECT COUNT(*) as cnt FROM log_collect_raw WHERE is_success = 1 AND request_time > ?',
[testStartTime]
);
const dbFailLogs = await queryLog(
'SELECT COUNT(*) as cnt FROM log_collect_raw WHERE is_success = 0 AND request_time > ?',
[testStartTime]
);
// 按设备统计
const machines = await queryBusiness(
'SELECT id, device_code, name FROM cnc_machine WHERE collect_address_id = 1'
);
let deviceStats = '';
for (const m of machines) {
const segs = await queryBusiness(
`SELECT program_name, SUM(quantity) as total_qty, COUNT(*) as seg_count, close_reason
FROM cnc_production_segment WHERE machine_id = ? AND created_at > ?
GROUP BY program_name`,
[m.id, testStartTime]
);
const totalQty = segs.reduce((s: number, r: any) => s + (Number(r.total_qty) || 0), 0);
const programs = segs.map((r: any) => `${r.program_name}(${r.total_qty}个/${r.seg_count}段)`).join(', ');
deviceStats += `| ${m.device_code} | ${m.name} | ${totalQty} | ${segs.length} | ${programs} |\n`;
}
// 阶段时间表
let phaseTable = '';
const phaseNames: Record<string, string> = {
'normal1_start': '阶段1: 正常采集开始',
'http500_start': '阶段2: HTTP 500异常开始',
'http500_end': '阶段2: HTTP 500异常结束',
'timeout_start': '阶段3: 超时异常开始',
'timeout_end': '阶段3: 超时异常结束',
'empty_start': '阶段4: 空数据返回开始',
'empty_end': '阶段4: 空数据返回结束',
'refuse_start': '阶段5: 拒绝连接开始',
'refuse_end': '阶段5: 拒绝连接结束',
'normal2_start': '阶段6: 恢复正常开始',
'normal2_end': '阶段6: 恢复正常结束',
};
for (const [key, name] of Object.entries(phaseNames)) {
const ts = phaseTimestamps[key];
if (ts) {
phaseTable += `| ${name} | ${ts.toLocaleString('zh-CN')} |\n`;
}
}
// 事件历史摘要
let eventSummary = '';
if (Array.isArray(eventHistoryData)) {
const eventTypeCounts: Record<string, number> = {};
for (const evt of eventHistoryData) {
const type = evt.eventType || 'unknown';
eventTypeCounts[type] = (eventTypeCounts[type] || 0) + 1;
}
for (const [type, count] of Object.entries(eventTypeCounts)) {
eventSummary += `| ${type} | ${count} |\n`;
}
}
// 异常日志摘要
let errorSummary = '';
if (Array.isArray(errorLogData) && errorLogData.length > 0) {
for (const err of errorLogData.slice(0, 20)) {
errorSummary += `| ${err.timestamp} | ${err.errorType} | ${err.description} | ${err.affectedDevices} |\n`;
}
}
const report = `# 15分钟自动化采集测试报告
**:** ${new Date().toLocaleString('zh-CN')}
**:** ${testStartTime.toLocaleString('zh-CN')}
**:** ${new Date().toLocaleString('zh-CN')}
**:** ${Math.round((Date.now() - testStartTime.getTime()) / 60000)}
---
##
| | |
|------|------|
| | ${simulationPort} |
| | 10 |
| | ${machines.length} |
| | http://localhost:${simulationPort}/ (id=1) |
##
| | |
|------|------|
| | ${testStartTime.toLocaleString('zh-CN')} |
${phaseTable}
| | ${new Date().toLocaleString('zh-CN')} |
##
| | |
|------|------|
| | ${simSummary.totalDevices || '?'} |
| 线 | ${simSummary.onlineDevices || '?'} |
| | ${simSummary.totalParts || '?'} |
| | ${simSummary.totalRequests || '?'} |
| | ${simSummary.successRequests || '?'} |
| | ${simSummary.failRequests || '?'} |
| | ${simSummary.errorCount || 0} |
##
| | |
|------|------|
| (cnc_collect_record) | ${dbRecords[0].cnt} |
| (cnc_production_segment) | ${dbSegments[0].cnt} |
| | ${dbSettled[0].cnt} |
| (log_collect_raw) | ${dbRawLogs[0].cnt} |
| | ${dbSuccessLogs[0].cnt} |
| | ${dbFailLogs[0].cnt} |
##
| | | | | |
|---------|---------|-------------|--------|---------|
${deviceStats}
##
| | |
|---------|------|
${eventSummary || '| (无事件) | 0 |'}
##
| | | | |
|------|------|------|-----------|
${errorSummary || '| (无异常记录) | - | - | - |'}
##
${mismatchCount === 0 ? '✅ 所有设备零件数量完全匹配,数据采集准确性验证通过。' : `⚠️ 有 ${mismatchCount} 台设备零件数存在差异,需进一步排查。`}
- : ${dbRecords[0].cnt}
- : ${dbSegments[0].cnt} ( ${dbSettled[0].cnt})
- : ${dbRawLogs[0].cnt} ( ${dbSuccessLogs[0].cnt}, ${dbFailLogs[0].cnt})
- : ${phaseTimestamps['normal2_start'] ? '✅ 恢复后数据正常产生' : '❌ 未验证'}
---
* Playwright *
`;
fs.writeFileSync(reportPath, report, 'utf-8');
log(`\n===== 测试报告已生成 =====`);
log(` 路径: ${reportPath}`);
log(`\n===== 15分钟自动化采集测试结束 =====\n`);
});
});

@ -25,6 +25,19 @@ namespace CncSimulator.Core
public long Duration { get; set; } public long Duration { get; set; }
} }
/// <summary>异常记录条目</summary>
public class ErrorRecord
{
/// <summary>时间戳</summary>
public DateTime Timestamp { get; set; }
/// <summary>异常类型: http500, timeout, empty, malformed, refuse</summary>
public string ErrorType { get; set; }
/// <summary>异常描述</summary>
public string Description { get; set; }
/// <summary>影响设备数</summary>
public int AffectedDevices { get; set; }
}
/// <summary> /// <summary>
/// 日志记录器。同时写入内存环形缓冲和log4net文件。 /// 日志记录器。同时写入内存环形缓冲和log4net文件。
/// </summary> /// </summary>
@ -37,6 +50,10 @@ namespace CncSimulator.Core
private readonly object _lock = new object(); private readonly object _lock = new object();
private readonly log4net.ILog _log; private readonly log4net.ILog _log;
// 异常记录列表(独立于请求日志)
private readonly List<ErrorRecord> _errors = new List<ErrorRecord>();
private readonly object _errorLock = new object();
public LogRecorder(int capacity = 200) public LogRecorder(int capacity = 200)
{ {
_capacity = capacity; _capacity = capacity;
@ -71,6 +88,40 @@ namespace CncSimulator.Core
_log.Info($"[{addressPort}] 关键数据: {keyData}"); _log.Info($"[{addressPort}] 关键数据: {keyData}");
} }
/// <summary>记录一次异常</summary>
public void RecordError(string errorType, string description, int affectedDevices)
{
var record = new ErrorRecord
{
Timestamp = DateTime.Now,
ErrorType = errorType,
Description = description,
AffectedDevices = affectedDevices
};
lock (_errorLock)
{
_errors.Add(record);
// 限制数量
if (_errors.Count > 1000) _errors.RemoveAt(0);
}
_log.Warn($"[异常] 类型={errorType}, 影响{affectedDevices}台设备: {description}");
}
/// <summary>获取所有异常记录</summary>
public List<ErrorRecord> GetErrors()
{
lock (_errorLock)
{
return new List<ErrorRecord>(_errors);
}
}
/// <summary>获取异常次数统计</summary>
public int GetErrorCount()
{
lock (_errorLock) { return _errors.Count; }
}
/// <summary>获取最近的日志</summary> /// <summary>获取最近的日志</summary>
public List<LogEntry> GetRecentLogs(int count) public List<LogEntry> GetRecentLogs(int count)
{ {

@ -427,19 +427,23 @@ namespace CncSimulator.Core
{ {
case "http500": case "http500":
_failCount++; _failCount++;
_logRecorder.RecordError("http500", "模拟HTTP 500错误", OnlineDeviceCount);
SendTextResponse(ctx, 500, "Internal Server Error (模拟)"); SendTextResponse(ctx, 500, "Internal Server Error (模拟)");
return; return;
case "timeout": case "timeout":
_failCount++; _failCount++;
_logRecorder.RecordError("timeout", "模拟超时响应(60秒延迟)", OnlineDeviceCount);
System.Threading.Thread.Sleep(60000); System.Threading.Thread.Sleep(60000);
SendTextResponse(ctx, 200, "delayed response"); SendTextResponse(ctx, 200, "delayed response");
return; return;
case "empty": case "empty":
_successCount++; _successCount++;
_logRecorder.RecordError("empty", "模拟空数据返回([])", OnlineDeviceCount);
SendTextResponse(ctx, 200, "[]", "application/json"); SendTextResponse(ctx, 200, "[]", "application/json");
return; return;
case "malformed": case "malformed":
_successCount++; _successCount++;
_logRecorder.RecordError("malformed", "模拟畸形JSON返回({broken)", OnlineDeviceCount);
SendTextResponse(ctx, 200, "{broken", "application/json"); SendTextResponse(ctx, 200, "{broken", "application/json");
return; return;
} }
@ -579,12 +583,119 @@ namespace CncSimulator.Core
SendTextResponse(ctx, 200, removed ? "{\"ok\":true}" : "{\"ok\":false}", "application/json"); SendTextResponse(ctx, 200, removed ? "{\"ok\":true}" : "{\"ok\":false}", "application/json");
break; break;
case "/admin/api/event-history":
HandleEventHistoryApi(ctx);
break;
case "/admin/api/full-summary":
HandleFullSummaryApi(ctx);
break;
case "/admin/api/error-log":
HandleErrorLogApi(ctx);
break;
default: default:
SendJsonResponse(ctx, 404, "{\"error\":\"Unknown API\"}"); SendJsonResponse(ctx, 404, "{\"error\":\"Unknown API\"}");
break; break;
} }
} }
// ===== 新增API处理方法 =====
/// <summary>事件历史API返回所有设备的事件变更记录</summary>
private void HandleEventHistoryApi(HttpListenerContext ctx)
{
var allEvents = new JArray();
foreach (var dev in _devices)
{
foreach (var evt in dev.State.EventHistory)
{
allEvents.Add(new JObject
{
["timestamp"] = evt.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"),
["deviceCode"] = evt.DeviceCode,
["eventType"] = evt.EventType,
["oldProgram"] = evt.OldProgram,
["newProgram"] = evt.NewProgram,
["partCountBefore"] = evt.PartCountBefore,
["partCountAfter"] = evt.PartCountAfter,
["detail"] = evt.Detail
});
}
}
SendTextResponse(ctx, 200, allEvents.ToString(), "application/json");
}
/// <summary>完整汇总导出API用于测试结束后数据对比</summary>
private void HandleFullSummaryApi(HttpListenerContext ctx)
{
var devices = new JArray();
foreach (var dev in _devices)
{
var s = dev.State;
var programs = new JObject();
foreach (var kvp in s.PartsByProgram)
{
programs[kvp.Key] = kvp.Value;
}
devices.Add(new JObject
{
["deviceCode"] = s.DeviceCode,
["desc"] = s.Desc,
["isOnline"] = s.IsOnline,
["currentProgram"] = s.ProgramName,
["currentPartCount"] = s.PartCount,
["totalParts"] = s.TotalPartsSinceStart,
["programs"] = programs,
["eventCount"] = s.EventHistory.Count,
["lastEvent"] = s.EventHistory.Count > 0
? new JObject
{
["type"] = s.EventHistory[s.EventHistory.Count - 1].EventType,
["time"] = s.EventHistory[s.EventHistory.Count - 1].Timestamp.ToString("yyyy-MM-dd HH:mm:ss")
}
: null
});
}
var summary = new JObject
{
["exportTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
["addressName"] = _config.Name,
["port"] = _config.Port,
["startTime"] = _startTime.ToString("yyyy-MM-dd HH:mm:ss"),
["uptime"] = (DateTime.Now - _startTime).ToString(@"hh\:mm\:ss"),
["totalDevices"] = _devices.Count,
["onlineDevices"] = OnlineDeviceCount,
["totalParts"] = GetTotalParts(),
["totalRequests"] = _requestCount,
["successRequests"] = _successCount,
["failRequests"] = _failCount,
["errorCount"] = _logRecorder.GetErrorCount(),
["devices"] = devices
};
SendTextResponse(ctx, 200, summary.ToString(), "application/json");
}
/// <summary>异常日志API返回所有异常记录</summary>
private void HandleErrorLogApi(HttpListenerContext ctx)
{
var errors = _logRecorder.GetErrors();
var arr = new JArray();
foreach (var err in errors)
{
arr.Add(new JObject
{
["timestamp"] = err.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"),
["errorType"] = err.ErrorType,
["description"] = err.Description,
["affectedDevices"] = err.AffectedDevices
});
}
SendTextResponse(ctx, 200, arr.ToString(), "application/json");
}
// ===== HTTP辅助方法 ===== // ===== HTTP辅助方法 =====
private string ReadRequestBody(HttpListenerContext ctx) private string ReadRequestBody(HttpListenerContext ctx)

@ -178,6 +178,7 @@ namespace CncSimulator.Device
private void ApplyProgramChange() private void ApplyProgramChange()
{ {
string oldProgram = _state.ProgramName; string oldProgram = _state.ProgramName;
int oldPartCount = _state.PartCount;
_programPoolIndex = (_programPoolIndex + 1) % ProgramPool.Length; _programPoolIndex = (_programPoolIndex + 1) % ProgramPool.Length;
_state.ProgramName = ProgramPool[_programPoolIndex]; _state.ProgramName = ProgramPool[_programPoolIndex];
_state.PartCount = 0; _state.PartCount = 0;
@ -190,14 +191,19 @@ namespace CncSimulator.Device
_state.FeedSpeedSet = _rng.Next(30, 151); _state.FeedSpeedSet = _rng.Next(30, 151);
_state.SpindleOverride = 100; _state.SpindleOverride = 100;
_state.ProgramContent = "<" + _state.ProgramName + ">\nG40G49G80\n( SIMULATOR )"; _state.ProgramContent = "<" + _state.ProgramName + ">\nG40G49G80\n( SIMULATOR )";
// 记录事件历史
_state.RecordEvent("program_change", oldProgram, _state.ProgramName, oldPartCount, 0);
} }
/// <summary>手动清零</summary> /// <summary>手动清零</summary>
private void ApplyManualReset() private void ApplyManualReset()
{ {
int oldPartCount = _state.PartCount;
_state.PartCount = 0; _state.PartCount = 0;
_state.RunStatus = 3; _state.RunStatus = 3;
_state.DeviceStatus = 1; _state.DeviceStatus = 1;
// 记录事件历史
_state.RecordEvent("manual_reset", _state.ProgramName, _state.ProgramName, oldPartCount, 0);
} }
/// <summary>断电</summary> /// <summary>断电</summary>
@ -205,11 +211,14 @@ namespace CncSimulator.Device
{ {
_state.IsOnline = false; _state.IsOnline = false;
_state.DeviceStatus = 0; _state.DeviceStatus = 0;
// 记录事件历史
_state.RecordEvent("power_off", _state.ProgramName, _state.ProgramName, _state.PartCount, _state.PartCount, "设备断电");
} }
/// <summary>恢复开机</summary> /// <summary>恢复开机</summary>
private void ApplyPowerOn() private void ApplyPowerOn()
{ {
int oldPartCount = _state.PartCount;
_state.IsOnline = true; _state.IsOnline = true;
_state.DeviceStatus = 1; _state.DeviceStatus = 1;
_state.PartCount = 0; _state.PartCount = 0;
@ -220,6 +229,8 @@ namespace CncSimulator.Device
_state.FeedSpeedActual = 0; _state.FeedSpeedActual = 0;
_state.SpindleLoad = 0; _state.SpindleLoad = 0;
_state.MachiningStatus = ""; _state.MachiningStatus = "";
// 记录事件历史
_state.RecordEvent("power_on", _state.ProgramName, _state.ProgramName, oldPartCount, 0, "设备开机,零件数清零");
} }
} }
} }

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace CncSimulator.Device namespace CncSimulator.Device
@ -87,5 +88,49 @@ namespace CncSimulator.Device
/// <summary>按NC程序名统计的零件数程序名 → 零件数)</summary> /// <summary>按NC程序名统计的零件数程序名 → 零件数)</summary>
public Dictionary<string, int> PartsByProgram { get; set; } = new Dictionary<string, int>(); public Dictionary<string, int> PartsByProgram { get; set; } = new Dictionary<string, int>();
// ===== 事件历史 =====
/// <summary>事件变更历史记录最多保留1000条</summary>
public List<EventRecord> EventHistory { get; set; } = new List<EventRecord>();
/// <summary>记录一次事件</summary>
public void RecordEvent(string eventType, string oldProgram, string newProgram, int partCountBefore, int partCountAfter, string detail = null)
{
var record = new EventRecord
{
Timestamp = DateTime.Now,
DeviceCode = DeviceCode,
EventType = eventType,
OldProgram = oldProgram ?? "",
NewProgram = newProgram ?? "",
PartCountBefore = partCountBefore,
PartCountAfter = partCountAfter,
Detail = detail ?? ""
};
EventHistory.Add(record);
// 限制历史记录数量
if (EventHistory.Count > 1000) EventHistory.RemoveAt(0);
}
}
/// <summary>设备事件记录</summary>
public class EventRecord
{
/// <summary>事件时间</summary>
public DateTime Timestamp { get; set; }
/// <summary>设备编码</summary>
public string DeviceCode { get; set; }
/// <summary>事件类型: program_change, manual_reset, power_off, power_on, scenario_change</summary>
public string EventType { get; set; }
/// <summary>变更前程序名</summary>
public string OldProgram { get; set; }
/// <summary>变更后程序名</summary>
public string NewProgram { get; set; }
/// <summary>变更前零件数</summary>
public int PartCountBefore { get; set; }
/// <summary>变更后零件数</summary>
public int PartCountAfter { get; set; }
/// <summary>额外详情</summary>
public string Detail { get; set; }
} }
} }

Loading…
Cancel
Save