From 36cb666af32cd9d9d4d87eafa76404eded32af04 Mon Sep 17 00:00:00 2001 From: haoliang <821644@qq.com> Date: Sat, 2 May 2026 20:13:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E6=8B=9F=E5=99=A8=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=8E=86=E5=8F=B2/=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E6=97=A5=E5=BF=97/=E5=AE=8C=E6=95=B4=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?15=E5=88=86=E9=92=9F=E9=87=87=E9=9B=86=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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个异常阶段+数据对比验证+自动报告生成) --- .../scripts/collector-15min.spec.ts | 816 ++++++++++++++++++ src/CncSimulator/Core/LogRecorder.cs | 51 ++ src/CncSimulator/Core/SimulatorServer.cs | 111 +++ src/CncSimulator/Device/DeviceSimulator.cs | 11 + src/CncSimulator/Device/DeviceState.cs | 45 + 5 files changed, 1034 insertions(+) create mode 100644 src/CncCollector/scripts/collector-15min.spec.ts diff --git a/src/CncCollector/scripts/collector-15min.spec.ts b/src/CncCollector/scripts/collector-15min.spec.ts new file mode 100644 index 0000000..9e2a475 --- /dev/null +++ b/src/CncCollector/scripts/collector-15min.spec.ts @@ -0,0 +1,816 @@ +/** + * 15分钟自动化采集测试(含6个异常阶段+数据对比验证) + * + * 测试流程: + * 阶段0: 初始化 — 清空旧数据、启动模拟器、启动采集服务 + * 阶段1: 正常采集(5分钟)— 建立基准数据 + * 阶段2: HTTP 500异常(2分钟)— 模拟服务端错误 + * 阶段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 = {}; + +/** 每阶段结束时采集模拟器快照 */ +let phaseSnapshots: Record = {}; + +// ============================================================ +// 全局串行配置 +// ============================================================ + +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 = { + '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 = {}; + 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`); + }); + +}); diff --git a/src/CncSimulator/Core/LogRecorder.cs b/src/CncSimulator/Core/LogRecorder.cs index 0766804..b7c87dd 100644 --- a/src/CncSimulator/Core/LogRecorder.cs +++ b/src/CncSimulator/Core/LogRecorder.cs @@ -25,6 +25,19 @@ namespace CncSimulator.Core public long Duration { get; set; } } + /// 异常记录条目 + public class ErrorRecord + { + /// 时间戳 + public DateTime Timestamp { get; set; } + /// 异常类型: http500, timeout, empty, malformed, refuse + public string ErrorType { get; set; } + /// 异常描述 + public string Description { get; set; } + /// 影响设备数 + public int AffectedDevices { get; set; } + } + /// /// 日志记录器。同时写入内存环形缓冲和log4net文件。 /// @@ -37,6 +50,10 @@ namespace CncSimulator.Core private readonly object _lock = new object(); private readonly log4net.ILog _log; + // 异常记录列表(独立于请求日志) + private readonly List _errors = new List(); + private readonly object _errorLock = new object(); + public LogRecorder(int capacity = 200) { _capacity = capacity; @@ -71,6 +88,40 @@ namespace CncSimulator.Core _log.Info($"[{addressPort}] 关键数据: {keyData}"); } + /// 记录一次异常 + 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}"); + } + + /// 获取所有异常记录 + public List GetErrors() + { + lock (_errorLock) + { + return new List(_errors); + } + } + + /// 获取异常次数统计 + public int GetErrorCount() + { + lock (_errorLock) { return _errors.Count; } + } + /// 获取最近的日志 public List GetRecentLogs(int count) { diff --git a/src/CncSimulator/Core/SimulatorServer.cs b/src/CncSimulator/Core/SimulatorServer.cs index 83ddeea..8118579 100644 --- a/src/CncSimulator/Core/SimulatorServer.cs +++ b/src/CncSimulator/Core/SimulatorServer.cs @@ -427,19 +427,23 @@ namespace CncSimulator.Core { case "http500": _failCount++; + _logRecorder.RecordError("http500", "模拟HTTP 500错误", OnlineDeviceCount); SendTextResponse(ctx, 500, "Internal Server Error (模拟)"); return; case "timeout": _failCount++; + _logRecorder.RecordError("timeout", "模拟超时响应(60秒延迟)", OnlineDeviceCount); System.Threading.Thread.Sleep(60000); SendTextResponse(ctx, 200, "delayed response"); return; case "empty": _successCount++; + _logRecorder.RecordError("empty", "模拟空数据返回([])", OnlineDeviceCount); SendTextResponse(ctx, 200, "[]", "application/json"); return; case "malformed": _successCount++; + _logRecorder.RecordError("malformed", "模拟畸形JSON返回({broken)", OnlineDeviceCount); SendTextResponse(ctx, 200, "{broken", "application/json"); return; } @@ -579,12 +583,119 @@ namespace CncSimulator.Core SendTextResponse(ctx, 200, removed ? "{\"ok\":true}" : "{\"ok\":false}", "application/json"); 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: SendJsonResponse(ctx, 404, "{\"error\":\"Unknown API\"}"); break; } } + // ===== 新增API处理方法 ===== + + /// 事件历史API:返回所有设备的事件变更记录 + 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"); + } + + /// 完整汇总导出API:用于测试结束后数据对比 + 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"); + } + + /// 异常日志API:返回所有异常记录 + 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辅助方法 ===== private string ReadRequestBody(HttpListenerContext ctx) diff --git a/src/CncSimulator/Device/DeviceSimulator.cs b/src/CncSimulator/Device/DeviceSimulator.cs index 14ee454..0672f26 100644 --- a/src/CncSimulator/Device/DeviceSimulator.cs +++ b/src/CncSimulator/Device/DeviceSimulator.cs @@ -178,6 +178,7 @@ namespace CncSimulator.Device private void ApplyProgramChange() { string oldProgram = _state.ProgramName; + int oldPartCount = _state.PartCount; _programPoolIndex = (_programPoolIndex + 1) % ProgramPool.Length; _state.ProgramName = ProgramPool[_programPoolIndex]; _state.PartCount = 0; @@ -190,14 +191,19 @@ namespace CncSimulator.Device _state.FeedSpeedSet = _rng.Next(30, 151); _state.SpindleOverride = 100; _state.ProgramContent = "<" + _state.ProgramName + ">\nG40G49G80\n( SIMULATOR )"; + // 记录事件历史 + _state.RecordEvent("program_change", oldProgram, _state.ProgramName, oldPartCount, 0); } /// 手动清零 private void ApplyManualReset() { + int oldPartCount = _state.PartCount; _state.PartCount = 0; _state.RunStatus = 3; _state.DeviceStatus = 1; + // 记录事件历史 + _state.RecordEvent("manual_reset", _state.ProgramName, _state.ProgramName, oldPartCount, 0); } /// 断电 @@ -205,11 +211,14 @@ namespace CncSimulator.Device { _state.IsOnline = false; _state.DeviceStatus = 0; + // 记录事件历史 + _state.RecordEvent("power_off", _state.ProgramName, _state.ProgramName, _state.PartCount, _state.PartCount, "设备断电"); } /// 恢复开机 private void ApplyPowerOn() { + int oldPartCount = _state.PartCount; _state.IsOnline = true; _state.DeviceStatus = 1; _state.PartCount = 0; @@ -220,6 +229,8 @@ namespace CncSimulator.Device _state.FeedSpeedActual = 0; _state.SpindleLoad = 0; _state.MachiningStatus = ""; + // 记录事件历史 + _state.RecordEvent("power_on", _state.ProgramName, _state.ProgramName, oldPartCount, 0, "设备开机,零件数清零"); } } } diff --git a/src/CncSimulator/Device/DeviceState.cs b/src/CncSimulator/Device/DeviceState.cs index 713fc87..52ede52 100644 --- a/src/CncSimulator/Device/DeviceState.cs +++ b/src/CncSimulator/Device/DeviceState.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace CncSimulator.Device @@ -87,5 +88,49 @@ namespace CncSimulator.Device /// 按NC程序名统计的零件数(程序名 → 零件数) public Dictionary PartsByProgram { get; set; } = new Dictionary(); + + // ===== 事件历史 ===== + /// 事件变更历史记录(最多保留1000条) + public List EventHistory { get; set; } = new List(); + + /// 记录一次事件 + 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); + } + } + + /// 设备事件记录 + public class EventRecord + { + /// 事件时间 + public DateTime Timestamp { get; set; } + /// 设备编码 + public string DeviceCode { get; set; } + /// 事件类型: program_change, manual_reset, power_off, power_on, scenario_change + public string EventType { get; set; } + /// 变更前程序名 + public string OldProgram { get; set; } + /// 变更后程序名 + public string NewProgram { get; set; } + /// 变更前零件数 + public int PartCountBefore { get; set; } + /// 变更后零件数 + public int PartCountAfter { get; set; } + /// 额外详情 + public string Detail { get; set; } } }