Compare commits

..

2 Commits

Author SHA1 Message Date
haoliang e3f37d5433 feat: 实现采集分析引擎(AnalysisEngine)+ 后台管理API + 前端设计文档
- 新增 log_collect_analysis + log_collect_cycle 两张按月分区表DDL
- 完整实现 AnalysisEngine:9种分析类型检测、DB写入、异常告警联动
- 修改 CollectRecordWriter.WriteBatch 返回 rawLogId
- 集成 AnalysisEngine 到 CollectWorker 采集主流程
- 新增 CollectLogController 5个API端点(分析查询/详情/周期/原始日志)
- 新增 Entity/Enum/DTO/Repository/Service 全链路代码
- 修复子代理创建的文件:DTO命名空间、Repository方法名、SQL列映射、using引用
- 新增13-采集日志前端设计文档(索引+规范+页面)
- 全部5个主项目编译通过,0错误
1 day ago
haoliang 23eda3751f 新增采集日志服务接口 ICollectLogService、实现 CollectLogService、控制器 CollectLogController,并更新 API 文档 3.14 采集日志模块 1 day ago

@ -0,0 +1,93 @@
-- ============================================================
-- 采集分析日志表 + 采集周期汇总表(幂等迁移脚本)
-- 创建时间2026-05-05
-- 说明:在 cnc_log 库中新增两张按月分区表
-- log_collect_analysis: 每次采集、每台机床的分析记录
-- log_collect_cycle: 每次采集周期的汇总信息
-- 执行前提USE cnc_log; 已执行 01-init-schema.sql
-- ============================================================
USE cnc_log;
-- -----------------------------------------------------------
-- 1. 采集分析日志表 log_collect_analysis按月分区
-- 记录每次采集后对每台机床的数据变化分析
-- -----------------------------------------------------------
DROP TABLE IF EXISTS log_collect_analysis;
CREATE TABLE log_collect_analysis (
id BIGINT AUTO_INCREMENT,
analysis_time DATETIME NOT NULL COMMENT '分析时间(分区键)',
raw_log_id BIGINT NOT NULL COMMENT '关联原始日志IDlog_collect_raw.id',
collect_address_id INT NOT NULL COMMENT '采集地址ID关联cnc_collect_address',
machine_id INT NOT NULL COMMENT '机床ID关联cnc_machine',
analysis_type VARCHAR(30) NOT NULL COMMENT '分析类型NORMAL_UNCHANGED/PART_COUNT_INCREASE/PROGRAM_SWITCH/MANUAL_RESET/DEVICE_ONLINE/DEVICE_OFFLINE/NEW_DEVICE_FOUND/DATA_ANOMALY/COLLECTION_FAILED',
previous_program VARCHAR(200) NULL COMMENT '上一次NC程序名',
current_program VARCHAR(200) NULL COMMENT '本次NC程序名',
previous_part_count DECIMAL(15,5) NULL COMMENT '上一次零件计数',
current_part_count DECIMAL(15,5) NULL COMMENT '本次零件计数',
part_count_delta DECIMAL(15,5) NULL COMMENT '零件计数变化量(正=增加,负=减少)',
previous_status VARCHAR(20) NULL COMMENT '上一次设备状态',
current_status VARCHAR(20) NULL COMMENT '本次设备状态',
analysis_summary VARCHAR(500) NOT NULL COMMENT '人类可读的分析摘要',
analysis_detail JSON NULL COMMENT '完整的字段级对比数据JSON',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, analysis_time),
INDEX idx_address_time (collect_address_id, analysis_time),
INDEX idx_machine_time (machine_id, analysis_time),
INDEX idx_type_time (analysis_type, analysis_time),
INDEX idx_raw_log (raw_log_id),
INDEX idx_program_time (current_program, analysis_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='采集分析日志表(按月分区,记录每次采集对每台机床的数据变化分析)'
PARTITION BY RANGE (TO_DAYS(analysis_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- -----------------------------------------------------------
-- 2. 采集周期汇总表 log_collect_cycle按月分区
-- 记录每次采集周期(一个地址的一次完整采集)的汇总信息
-- -----------------------------------------------------------
DROP TABLE IF EXISTS log_collect_cycle;
CREATE TABLE log_collect_cycle (
id BIGINT AUTO_INCREMENT,
cycle_time DATETIME NOT NULL COMMENT '周期开始时间(分区键)',
collect_address_id INT NOT NULL COMMENT '采集地址ID关联cnc_collect_address',
raw_log_id BIGINT NOT NULL COMMENT '关联原始日志IDlog_collect_raw.id',
end_time DATETIME NULL COMMENT '周期结束时间',
duration_ms INT NULL COMMENT '本次采集总耗时(毫秒)',
total_machines INT NOT NULL DEFAULT 0 COMMENT '本周期采集的机床总数',
success_count INT NOT NULL DEFAULT 0 COMMENT '成功采集的机床数',
fail_count INT NOT NULL DEFAULT 0 COMMENT '失败采集的机床数',
change_distribution JSON NULL COMMENT '变化类型分布(如 {"PROGRAM_SWITCH":2,"PART_COUNT_INCREASE":5,"NORMAL_UNCHANGED":3}',
has_anomaly TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在异常1=有异常DATA_ANOMALY/COLLECTION_FAILED/DEVICE_OFFLINE',
cycle_summary VARCHAR(500) NULL COMMENT '人类可读的周期汇总',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, cycle_time),
INDEX idx_address_time (collect_address_id, cycle_time),
INDEX idx_time (cycle_time),
INDEX idx_anomaly_time (has_anomaly, cycle_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='采集周期汇总表(按月分区,每次采集周期的汇总信息)'
PARTITION BY RANGE (TO_DAYS(cycle_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- -----------------------------------------------------------
-- 3. 为现有 log_collect_raw 表增加补充索引
-- 支持按采集成功/失败筛选,以及按响应时长分析
-- -----------------------------------------------------------
-- 检查索引是否已存在,若不存在则添加(幂等)
SET @exist := (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'cnc_log' AND TABLE_NAME = 'log_collect_raw' AND INDEX_NAME = 'idx_success_time');
SET @sqlstmt := IF(@exist = 0,
'ALTER TABLE cnc_log.log_collect_raw ADD INDEX idx_success_time (is_success, request_time)',
'SELECT ''索引 idx_success_time 已存在,跳过''');
PREPARE stmt FROM @sqlstmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

@ -511,7 +511,7 @@ CREATE TABLE cnc_screen_filter (
---
## 三、日志库 cnc_log3张表)
## 三、日志库 cnc_log5张表)
### 3.1 原始采集JSON表 log_collect_raw按月分区
@ -594,6 +594,99 @@ CREATE TABLE log_collector_heartbeat (
---
### 3.4 采集分析日志表 log_collect_analysis按月分区
记录每次采集后对每台机床的数据变化分析。每次采集周期中,每台机床产生一条分析记录,包含与上一次采集数据的对比结果。
``sql
CREATE TABLE log_collect_analysis (
id BIGINT AUTO_INCREMENT,
analysis_time DATETIME NOT NULL COMMENT '分析时间(分区键)',
raw_log_id BIGINT NOT NULL COMMENT '关联原始日志IDlog_collect_raw.id',
collect_address_id INT NOT NULL COMMENT '采集地址ID关联cnc_collect_address',
machine_id INT NOT NULL COMMENT '机床ID关联cnc_machine',
analysis_type VARCHAR(30) NOT NULL COMMENT '分析类型枚举',
previous_program VARCHAR(200) NULL COMMENT '上一次NC程序名',
current_program VARCHAR(200) NULL COMMENT '本次NC程序名',
previous_part_count DECIMAL(15,5) NULL COMMENT '上一次零件计数',
current_part_count DECIMAL(15,5) NULL COMMENT '本次零件计数',
part_count_delta DECIMAL(15,5) NULL COMMENT '零件计数变化量(正=增加,负=减少)',
previous_status VARCHAR(20) NULL COMMENT '上一次设备状态',
current_status VARCHAR(20) NULL COMMENT '本次设备状态',
analysis_summary VARCHAR(500) NOT NULL COMMENT '人类可读的分析摘要',
analysis_detail JSON NULL COMMENT '完整的字段级对比数据JSON',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, analysis_time),
INDEX idx_address_time (collect_address_id, analysis_time),
INDEX idx_machine_time (machine_id, analysis_time),
INDEX idx_type_time (analysis_type, analysis_time),
INDEX idx_raw_log (raw_log_id),
INDEX idx_program_time (current_program, analysis_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='采集分析日志表(按月分区,记录每次采集对每台机床的数据变化分析)'
PARTITION BY RANGE (TO_DAYS(analysis_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
``
**analysis_type 分析类型枚举:**
| 值 | 含义 | 说明 |
|----|------|------|
| NORMAL_UNCHANGED | 正常无变化 | 数据与上次一致,正常加工中 |
| PART_COUNT_INCREASE | 零件数增加 | 零件计数增长,正常加工中 |
| PROGRAM_SWITCH | NC程序切换 | 程序名变更,触发上一段结账 |
| MANUAL_RESET | 手动清零 | 同程序下零件计数下降 |
| DEVICE_ONLINE | 设备上线 | 设备从离线恢复在线 |
| DEVICE_OFFLINE | 设备离线 | 设备变为离线状态 |
| NEW_DEVICE_FOUND | 发现新设备 | 采集到未注册的device |
| DATA_ANOMALY | 数据异常 | 字段缺失/格式错误/值异常 |
| COLLECTION_FAILED | 采集失败 | 本次采集请求失败 |
**数据量估算**每次采集周期×每台机床1条。假设每30秒1次采集×5-10个地址×每地址2-5台机床 = 600-2500条/分钟 = 约86-360万条/天。按月分区便于查询和清理。
---
### 3.5 采集周期汇总表 log_collect_cycle按月分区
记录每次采集周期一个地址的一次完整HTTP采集的汇总信息。一个周期对应 log_collect_raw 中的一条记录和 log_collect_analysis 中的多条记录。
``sql
CREATE TABLE log_collect_cycle (
id BIGINT AUTO_INCREMENT,
cycle_time DATETIME NOT NULL COMMENT '周期开始时间(分区键)',
collect_address_id INT NOT NULL COMMENT '采集地址ID关联cnc_collect_address',
raw_log_id BIGINT NOT NULL COMMENT '关联原始日志IDlog_collect_raw.id',
end_time DATETIME NULL COMMENT '周期结束时间',
duration_ms INT NULL COMMENT '本次采集总耗时(毫秒)',
total_machines INT NOT NULL DEFAULT 0 COMMENT '本周期采集的机床总数',
success_count INT NOT NULL DEFAULT 0 COMMENT '成功采集的机床数',
fail_count INT NOT NULL DEFAULT 0 COMMENT '失败采集的机床数',
change_distribution JSON NULL COMMENT '变化类型分布(如 {"PROGRAM_SWITCH":2,"PART_COUNT_INCREASE":5}',
has_anomaly TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在异常1=有异常)',
cycle_summary VARCHAR(500) NULL COMMENT '人类可读的周期汇总',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, cycle_time),
INDEX idx_address_time (collect_address_id, cycle_time),
INDEX idx_time (cycle_time),
INDEX idx_anomaly_time (has_anomaly, cycle_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='采集周期汇总表(按月分区,每次采集周期的汇总信息)'
PARTITION BY RANGE (TO_DAYS(cycle_time)) (
PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
``
**数据量估算**每次采集1条。每30秒×5-10个地址 = 10-20条/分钟 = 约1.4-2.9万条/天。远小于分析表,查询负担轻。
---
## 四、ER关系总览
`
@ -614,6 +707,10 @@ cnc_machine 1-----N cnc_alert
--- 日志库 ---
cnc_collect_address 1--N log_collect_raw (弱关联,跨库)
log_collect_raw 1--N log_collect_analysis (原始日志→分析记录)
log_collect_raw 1---1 log_collect_cycle (原始日志→周期汇总)
cnc_machine 1--N log_collect_analysis (弱关联,跨库)
cnc_collect_address 1--N log_collect_cycle (弱关联,跨库)
log_collector_heartbeat (独立弱关联collect_address_id)
log_system (独立)
`
@ -624,9 +721,9 @@ log_system (独立)
| 任务 | 频率 | 操作 |
|------|------|------|
| 创建新分区 | 每月1日 | 为cnc_collect_record、log_collect_raw、log_system预创建下下月分区 |
| 删除过期分区 | 每月1日 | DROP超过保留期的分区 |
| 清理心跳表 | 每天 | DELETE log_collector_heartbeat超过7天的记录 |
| 创建新分区 | 每月1日 | 为cnc_collect_record、log_collect_raw、log_system、log_collect_analysis、log_collect_cycle预创建下下月分区 |
| 删除过期分区 | 每月1日 | DROP超过保留期的分区(保留天数=0时不删除 |
| 清理心跳表 | 每天 | DELETE log_collector_heartbeat超过7天的记录(保留天数=0时不删除 |
| 清理告警表 | 每天 | DELETE cnc_alert已处理且超过180天的记录 |
---
@ -666,7 +763,16 @@ log_system (独立)
| log_collect_raw | idx_request_time | INDEX | 时间范围清理 |
| log_system | idx_level_time | INDEX | 按级别查错误日志 |
| log_system | idx_source_time | INDEX | 按来源查日志 |
| log_collect_raw | idx_success_time | INDEX | 按成功/失败筛选 |
| log_collect_analysis | idx_address_time | INDEX | 按采集地址+时间查分析 |
| log_collect_analysis | idx_machine_time | INDEX | 按机床+时间查分析 |
| log_collect_analysis | idx_type_time | INDEX | 按分析类型+时间查 |
| log_collect_analysis | idx_raw_log | INDEX | 按原始日志ID关联查 |
| log_collect_analysis | idx_program_time | INDEX | 按NC程序名+时间查 |
| log_collect_cycle | idx_address_time | INDEX | 按采集地址+时间查周期 |
| log_collect_cycle | idx_time | INDEX | 时间范围查询 |
| log_collect_cycle | idx_anomaly_time | INDEX | 查异常周期 |
| log_collector_heartbeat | idx_service_time | INDEX | 服务最新状态 |
| log_collector_heartbeat | idx_address_time | INDEX | 地址心跳历史 |
共计20张表34个索引含唯一索引
共计22张表43个索引(含唯一索引)

@ -0,0 +1,26 @@
# 采集日志 索引
> 版本v1.0
> 最后更新2026-04-25
---
## 模块概述
展示每次采集的分析日志、采集周期汇总和原始采集数据
## 页面清单
| 页面编号 | 页面名称 | 路由 | 功能概述 |
|---------|---------|------|---------|
| 13-01 | 采集日志页面 | /collect-log | 3个Tab采集周期+分析日志+原始数据) |
## 页面功能详情
### 13-01 采集日志页面
**路由**`/collect-log`
**功能概述**在同一页面以三个标签页展示采集周期、分析日志、原始数据支持弹窗查看对比信息和JSON原始数据。
**交互关系说明**:查看分析详情 → 弹窗展示字段对比;查看原始数据 → 弹窗展示JSON
---

@ -0,0 +1,131 @@
# 采集日志-规范
本规范用于管理后台「采集日志」模块的前端实现覆盖组件选用、数据表格定义、查询筛选、分页、时间选择、标签颜色映射及操作按钮等方面的设计约定确保UI风格统一、交互清晰、数据结构对齐后端接口。以下规范与现有模块风格保持一致参考文档界面变更执行规范、前端全局规范等。
## 1. 组件规范
- 数据展示:使用 Element Plus 的 el-table 及 el-table-column。
- 查询区域:使用 el-form、el-form-item、el-input、el-select、el-date-picker、el-time-picker如需等。
- 选项卡el-tabs、el-tab-pane分别承载分析记录、采集周期与原始数据三大区域。
- 弹窗与详情el-dialog 展示分析详情及原始日志原文等。
- 按钮与标签el-button、el-tag、el-tooltip 提供操作入口及信息提示。
- 提示与对齐:使用 el-message、el-notification 提供反馈,表格列对齐统一采用左对齐。
- 加载与空态:使用 el-skeleton、empty 组件作为加载与无数据态的占位显示。
## 2. 数据表格列定义
### Tab1分析记录 表格列
| 字段名 | 展示含义 | 注意事项 |
|---|---|---|
| time | 日志分析时间 | 日期时间格式统一为 yyyy-MM-dd HH:mm:ss |
| address | 采集地址 | 全局唯一识别码或名称 |
| machine | 机床 | 机器编号/名称 |
| type | 分析类型 | 参考下方标签颜色映射 |
| previousProgram | 前程序 | 准确的程序名 |
| currentProgram | 当前程序 | 当前正在执行的程序名 |
| yieldDelta | 产量变化 | 数值变化量,单位需一致 |
| summary | 摘要 | 简短描述分析结果 |
| actions | 操作 | 查看详情按钮等 |
### Tab2采集周期 表格列
| 字段名 | 展示含义 | 备注 |
|---|---|---|
| time | 周期开始时间 | 统一时间格式 |
| address | 采集地址 | |
| totalMachines | 总机床数 | 统计口径一致 |
| success | 成功次数 | |
| failure | 失败次数 | |
| anomaly | 异常次数 | |
| distribution | 数据分布摘要 | 摘要字段,方便快速浏览 |
| summary | 摘要 | 简要描述周期信息 |
### Tab3原始数据 表格列
| 字段名 | 展示含义 | 备注 |
|---|---|---|
| rawId | 日志原始ID | |
| logTime | 日志时间 | 解析时间戳 |
| contentPreview | 内容预览 | 仅显示摘要片段 |
| sourceAddress | 数据来源地址 | |
> 注:表格列定义仅为前端展现的约束,实际字段名称以后端接口返回字段为准。
## 3. 查询筛选条件
- 时间范围筛选:使用 el-date-picker 的 date 范围选择,格式 yyyy-MM-dd HH:mm:ss范围值作为请求的 startTime/endTime。
- 采集地址:下拉或输入框筛选,支持模糊匹配地址名称。
- 机床:下拉选择框,按可选机床列出。
- 分析类型多选筛选UI 采用 el-tag 形式展示筛选条件。
- 程序名:文本输入,用于匹配前程序或当前程序。
- 分页参数page、pageSizepageSize 默认为 20支持切换 [20, 50, 100]。
- 备注:筛选条件应可组合使用,且具备清空按钮重置。
## 4. 分页规范
- pageSize 选项20、50、100
- 分页控件样式el-pagination显示总条数、每页条数、页码跳转
- 数据加载时,应显示加载中状态,切换分页时防重复请求,避免并发冲突。
## 5. API 响应格式
- 前端对接后端 API 的统一响应格式为:
```
{ code: 0, message: "success", data: ... }
```
- 当 code 非 0 时,展示错误信息 message必要时提供可复现的错误提示。
## 6. 时间选择器规范
- 使用 el-date-picker类型为 daterange时间格式统一为 yyyy-MM-dd HH:mm:ssvalue-format 亦为 yyyy-MM-dd HH:mm:ss。
- 时间筛选优先级高于别的筛选条件;在无时区信息情况下以服务器时区为准。
- Tab1 的时间范围应以分析时间为准Tab2 的时间范围以周期起止时间为准。
## 7. 分析类型标签颜色映射
- NORMAL_UNCHANGED -> info
- PART_COUNT_INCREASE -> success
- PROGRAM_SWITCH -> warning
- MANUAL_RESET -> warning
- DEVICE_ONLINE -> success
- DEVICE_OFFLINE -> danger
- NEW_DEVICE_FOUND -> danger
- DATA_ANOMALY -> danger
- COLLECTION_FAILED -> danger
## 8. 操作按钮规范
- 行级操作:查看详情按钮,点击后弹出详情弹窗,展示分析详情或原始数据片段。
- 关联跳转:若需要跳转到关联页面,提供跳转按钮,并在按钮上标注目标路径或模块名称。
- 只在需要的场景启用导出、复制等辅助按钮,避免界面拥挤。
- 按钮颜色使用 Element Plus 默认颜色方案,确保与全局主题一致。
## 9. 路由与权限(简要)
- 路由路径:/collect-log遵循现有路由命名约定
- 页面权限:遵循统一的路由权限策略,必要时标注只读/编辑权限。
## 10. 错误处理与空态
- 网络异常、接口返回错误应给出清晰的错误信息提示。
- 数据为空时展示空态组件,辅以引导文本。
## 11. 视觉与可访问性
- 保持列宽一致,避免列数据溢出,必要时显示省略号并悬浮显示完整内容。
- 表格行高、文本颜色、对比度符合无障碍要求,确保在常用屏幕下可读。
## 12. 性能与缓存
- 大数据分页时采用服务端分页,前端仅请求当前页数据。
- 尽量使用简化字段,减少表格渲染开销。
## 13. 安全与数据脱敏
- 脱敏处理涉及敏感字段的展示,必要时对字段进行脱敏或隐藏。
- 请求应携带适用的鉴权信息,后端返回的数据不可直接暴露敏感字段。
## 14. 版本与兼容性
- 文档版本随代码同步更新,保持与后端接口版本一致。
- 如后端接口变更,及时在前端更新字段映射及展示逻辑。
## 15. 兼容性与国际化
- 支持简体中文显示,未来如扩展到多语言需提供翻译资源。
- UI 组件需要兼容主流浏览器,与公司统一浏览器兼容性要求一致。
## 16. 维护与扩展点
- 行为和字段若增加,必须更新对应的索引与页面文档。
- 新增字段应通过后端接口文档对齐,并同步 Mock 数据结构。
## 17. 附加说明
- 本规范仅定义前端展示层的通用原则,具体字段名称以后端接口返回字段为准。
- 如遇特殊场景,需与后端对接团队共同确认后再实现。
---
备注:如需对照其他模块的设计风格,请参考文档:`docs/02-功能清单/07-告警管理/`等的规范表述。

@ -0,0 +1,117 @@
# 13-01 采集日志页面
_
本文档按照20项模板撰写用于前端实现“采集日志页面”的设计与交互规范。页面在管理后台中放置于 /collect-log 路由下包含三个标签页分析记录、采集周期、原始数据。_
## 1. 页面基本信息
- 模块:管理后台 -> 采集日志
- 页面名称:采集日志页面
- 路由:/collect-log
- 版本v1.0
- 作者:设计/前端团队
- 依赖Element Plus、Vue 3、TypeScript
## 2. 布局结构
- 顶部区域:查询条件区域(时间范围、地址、机床、分析类型、程序名等)
- 中部区域Tabs 切换,包含三个 Tab分析记录、采集周期、原始数据
- 各 Tab 之下为各自的表格 + 分页控件
- 底部/弹窗区域:查看详情弹窗、关联跳转入口
## 3. 数据表格列定义
- 分析记录Tab1时间、地址、机床、分析类型、前程序、当前程序、产量变化、摘要、操作
- 采集周期Tab2时间、地址、总机床、成功、失败、异常、分布、摘要
- 原始数据Tab3原始日志ID、时间、内容摘要、来源地址
## 4. 查询条件字段
- 时间范围:日期时间范围选择器,格式 yyyy-MM-dd HH:mm:ss
- 采集地址:下拉或文本输入,支持模糊匹配
- 机床:下拉选择
- 分析类型:多选过滤标签
- 程序名:文本输入
- 提交触发:查询按钮,重置按钮
- 每页显示条数:分页组件控制
## 5. API端点定义
- GET /api/admin/collect-log/analysis
- GET /api/admin/collect-log/analysis/{id}
- GET /api/admin/collect-log/analysis/by-raw/{rawLogId}
- GET /api/admin/collect-log/cycle
- GET /api/admin/collect-log/raw
## 6. Mock数据结构
- 分析记录列表 Mock数组对象包含 time、address、machine、type、previousProgram、currentProgram、yieldDelta、summary
- 分析详情 Mock含 detail 字段、difference 和对比数据
- 周期数据 Mocktime、address、totalMachines、success、failure、anomaly、distribution、summary
- 原始数据 MockrawId、logTime、contentPreview、sourceAddress
## 7. 交互行为
- 标签页切换:切换时重新加载对应表格数据
- 分页:点击页码、切换每页条数时加载对应页数据
- 查看详情弹窗:选中行后弹出,展示分析详情或对比信息
- 关联跳转:点击相关行的跳转按钮,跳转至对应的关联页面
- 原始数据查看:点击原始数据行,弹出 JSON 原始数据预览
## 8. 组件树
- 组件树示例CollectLogPage -> (QueryForm, ElTabs -> (AnalysisTab, CycleTab, RawTab) -> (ElTable, ElPagination)) -> DetailDialog
- 表单控件QueryFormel-form、el-form-item、el-date-picker、el-input、el-select、el-tag
- 表格与分页el-table、el-table-column、el-pagination
- 弹窗el-dialog
- 细化的子组件AnalysisTable、CycleTable、RawTable、DetailDialog可复用
## 9. 路由配置
- 路由路径:/collect-log
- 路由组件CollectLogPage
- 路由守卫:同其他管理后台页面的权限控制
- 嵌套路由(若有需要):/collect-log/analysis、/collect-log/cycle、/collect-log/raw
## 10. 数据校验与错误处理
- 搜索条件必填项的格式校验:时间范围格式、文本字段长度等
- API 请求失败时,展示友好错误信息并保留上一次有效数据展示
- 弹窗中的对比信息若数据为空,显示空态提示
## 11. 性能与优化点
- 分页按需加载,避免一次性加载所有数据
- 表格列尽量避免使用复杂自定义渲染,必要时使用虚拟滚动
- 原始数据区域对大文本使用内容摘要展示,点击展开查看全文
## 12. 国际化与无障碍
- 暂定中文显示,未来支持多语言资源
- 组件具备基础无障碍特性,表格可读性良好
## 13. 数据引用与结构(接口关联)
- 参考后端 API 端点,数据结构需与后端返回字段严格对齐
- 需在页面中以常量形式存放端点引用及字段映射,便于维护
## 14. 组件样式与风格
- 遵循全局主题,表格列宽可自适应,必要时固定宽度以确保对齐
- 按钮、标签、弹窗风格与全局规范保持一致
## 15. 版本与变更记录
- 每次变更需记录版本号和变更摘要,便于回溯
- 与模块索引、总览文档保持同步
## 16. 技术实现概要
- 主要使用 Vue 3 + TypeScript + Element Plus按项目的前端全局规范实现
- 数据获取走统一的 API 调用封装,错误统一处理
- UI 组件具有可复用性,便于其他模块复用
## 17. 数据流与状态管理
- 页面局部状态通过 Vue 的响应式系统管理
- 表格数据、筛选条件和分页状态保持在组件状态中,必要时通过 Store/Pinia 共享
## 18. 测试用例设计
- 基本渲染测试:页面渲染、表格列正确显示
- 筛选与分页功能测试
- 弹窗查看详情测试
- 跳转与联动测试
## 19. 部署与运行
- 本地调试:确保 /collect-log 路由可访问,接口 Mock/测试环境数据正常
- 与后端联调时保持端点一致,返回字段映射不变
## 20. 变更记录
- 记录本页面文档的变更时间、版本和修改内容,便于团队追踪
---
备注:本文档遵循文档结构规范,确保与其他模块文档风格一致,如需对齐请参考 `docs/02-功能清单/02-文件夹创建规范.md``docs/02-界面变更执行规范.md`

@ -396,3 +396,18 @@
4. 后端按正式API列实现接口返回数据结构严格对齐页面文件§9的定义
5. 前端 Mock 开发时按 Mock URL 列调用正式联调时切换到正式API列
6. 禁止只新增一列而遗漏另一列——每次新增接口必须两列同时更新
### 3.14 采集日志模块
| 端点 | Method | Mock | 说明 |
|------|--------|------|------|
| /api/admin/collect-log/analysis | GET | /mock-api/admin/collect-log/analysis | 采集分析日志(分页) |
| /api/admin/collect-log/analysis/{id} | GET | /mock/api/admin/collect-log/analysis/{id} | 分析详情 |
| /api/admin/collect-log/analysis/by-raw/{rawLogId} | GET | /mock/api/admin/collect-log/analysis/by-raw/{rawLogId} | 按原始日志查分析 |
| /api/admin/collect-log/cycle | GET | /mock-api/admin/collect-log/cycle | 采集周期(分页) |
| /api/admin/collect-log/raw | GET | /mock-api/admin/collect-log/raw | 原始采集数据(分页) |
**查询参数说明:**
- analysis端点startDate, endDate, collectAddressId, machineId, analysisType, programName, page, pageSize
- cycle端点startDate, endDate, collectAddressId, hasAnomaly, page, pageSize
- raw端点startDate, endDate, collectAddressId, isSuccess, page, pageSize

@ -0,0 +1,401 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Dapper;
using MySqlConnector;
using Newtonsoft.Json;
using CncModels.Entity;
using log4net;
namespace CncCollector.Core
{
/// <summary>
/// 采集分析引擎。
/// 在每次采集周期后,对比每台机床的当前数据与上一次采集数据,
/// 生成分析记录log_collect_analysis和周期汇总log_collect_cycle
/// 异常类分析自动写入告警表cnc_alert
/// </summary>
public class AnalysisEngine
{
private static readonly ILog _log = LogManager.GetLogger(typeof(AnalysisEngine));
/// <summary>业务库连接字符串(写告警用)</summary>
private readonly string _businessConnStr;
/// <summary>日志库连接字符串(写分析/周期表用)</summary>
private readonly string _logConnStr;
/// <summary>内存缓存machineId → 上一次采集状态快照</summary>
private readonly ConcurrentDictionary<int, MachineSnapshot> _lastSnapshot = new ConcurrentDictionary<int, MachineSnapshot>();
/// <summary>
/// 采集缓存快照(每台机床的上一次状态)
/// </summary>
public class MachineSnapshot
{
public string ProgramName { get; set; }
public decimal? PartCount { get; set; }
public string DeviceStatus { get; set; }
public DateTime CollectTime { get; set; }
}
/// <summary>
/// 初始化分析引擎
/// </summary>
/// <param name="businessConnStr">业务库连接字符串</param>
/// <param name="logConnStr">日志库连接字符串</param>
public AnalysisEngine(string businessConnStr, string logConnStr)
{
_businessConnStr = businessConnStr ?? throw new ArgumentNullException(nameof(businessConnStr));
_logConnStr = logConnStr ?? throw new ArgumentNullException(nameof(logConnStr));
}
/// <summary>
/// 分析一次采集周期的所有设备数据,写入分析记录和周期汇总。
/// </summary>
/// <param name="rawLogId">本次原始日志IDlog_collect_raw.id</param>
/// <param name="collectAddressId">采集地址ID</param>
/// <param name="addressName">采集地址名称</param>
/// <param name="records">本次采集的结构化记录列表</param>
/// <param name="machineDict">device_code → Machine 的查找字典</param>
/// <param name="cycleStartTime">周期开始时间</param>
/// <param name="durationMs">本次采集耗时(毫秒)</param>
public void AnalyzeAndRecord(long rawLogId, int collectAddressId, string addressName,
List<CollectRecord> records, Dictionary<string, Machine> machineDict,
DateTime cycleStartTime, long durationMs)
{
if (records == null || records.Count == 0) return;
try
{
var analysisTime = DateTime.Now;
var hasAnomaly = false;
var changeDistribution = new Dictionary<string, int>();
int successCount = 0;
// 构建 machineId → Machine 查找字典
var machineById = new Dictionary<int, Machine>();
foreach (var m in machineDict.Values)
{
machineById[m.Id] = m;
}
// 逐条分析
foreach (var rec in records)
{
try
{
// 获取机床信息
Machine machine = null;
machineById.TryGetValue(rec.MachineId, out machine);
string machineName = machine?.Name ?? ("机床" + rec.MachineId);
// 当前值
string currentProgram = rec.ProgramName;
decimal? currentPartCount = rec.PartCount;
string currentStatus = rec.DeviceStatus;
// 获取上次快照
MachineSnapshot prev;
_lastSnapshot.TryGetValue(rec.MachineId, out prev);
// 计算分析类型和摘要
string analysisType;
string summary;
bool needAlert = false;
string alertType = null;
DetermineAnalysis(prev, currentProgram, currentPartCount, currentStatus,
machineName, out analysisType, out summary, out needAlert, out alertType);
// 计算变化量
decimal? partCountDelta = null;
if (currentPartCount.HasValue && prev != null && prev.PartCount.HasValue)
{
partCountDelta = currentPartCount.Value - prev.PartCount.Value;
}
// 构建分析明细JSON
var detail = new
{
previous = prev != null ? new
{
program = prev.ProgramName,
partCount = prev.PartCount,
status = prev.DeviceStatus
} : null,
current = new
{
program = currentProgram,
partCount = currentPartCount,
status = currentStatus
},
delta = new { partCount = partCountDelta },
collectTime = rec.CollectTime.ToString("yyyy-MM-dd HH:mm:ss")
};
string detailJson = JsonConvert.SerializeObject(detail);
// 写入分析记录
WriteAnalysis(new CncModels.Entity.CollectAnalysis
{
AnalysisTime = analysisTime,
RawLogId = rawLogId,
CollectAddressId = collectAddressId,
MachineId = rec.MachineId,
AnalysisType = analysisType,
PreviousProgram = prev?.ProgramName,
CurrentProgram = currentProgram,
PreviousPartCount = prev?.PartCount,
CurrentPartCount = currentPartCount,
PartCountDelta = partCountDelta,
PreviousStatus = prev?.DeviceStatus,
CurrentStatus = currentStatus,
AnalysisSummary = summary,
AnalysisDetail = detailJson
});
// 更新快照
_lastSnapshot[rec.MachineId] = new MachineSnapshot
{
ProgramName = currentProgram,
PartCount = currentPartCount,
DeviceStatus = currentStatus,
CollectTime = rec.CollectTime
};
// 统计分布
if (changeDistribution.ContainsKey(analysisType))
changeDistribution[analysisType]++;
else
changeDistribution[analysisType] = 1;
// 异常告警
if (needAlert)
{
hasAnomaly = true;
WriteAlert(alertType, rec.MachineId, collectAddressId, summary, detailJson);
}
// 统计成功数(非异常即为成功)
if (analysisType != "COLLECTION_FAILED" && analysisType != "DATA_ANOMALY")
successCount++;
}
catch (Exception ex)
{
_log.Error($"分析单条记录失败machineId={rec.MachineId}", ex);
}
}
// 写入周期汇总
WriteCycleSummary(new CncModels.Entity.CollectCycle
{
CycleTime = cycleStartTime,
CollectAddressId = collectAddressId,
RawLogId = rawLogId,
EndTime = analysisTime,
DurationMs = (int)durationMs,
TotalMachines = records.Count,
SuccessCount = successCount,
FailCount = records.Count - successCount,
ChangeDistribution = JsonConvert.SerializeObject(changeDistribution),
HasAnomaly = hasAnomaly ? 1 : 0,
CycleSummary = $"共{records.Count}台机床完成分析" + (hasAnomaly ? ",存在异常" : "")
});
}
catch (Exception ex)
{
_log.Error($"采集分析失败(地址={addressName}, rawLogId={rawLogId}", ex);
}
}
/// <summary>
/// 根据前后状态对比,确定分析类型
/// </summary>
private void DetermineAnalysis(MachineSnapshot prev, string currentProgram, decimal? currentPartCount,
string currentStatus, string machineName, out string analysisType, out string summary,
out bool needAlert, out string alertType)
{
needAlert = false;
alertType = null;
// 无历史快照 → 首次上线
if (prev == null)
{
analysisType = "DEVICE_ONLINE";
summary = $"机床{machineName}首次上线,程序={currentProgram ?? ""}";
needAlert = true;
alertType = "unknown_device";
return;
}
string prevProgram = prev.ProgramName;
decimal? prevPartCount = prev.PartCount;
string prevStatus = prev.DeviceStatus;
// 检测程序切换
if (!string.IsNullOrEmpty(currentProgram) && !string.IsNullOrEmpty(prevProgram) &&
!string.Equals(prevProgram, currentProgram, StringComparison.OrdinalIgnoreCase))
{
analysisType = "PROGRAM_SWITCH";
summary = $"机床{machineName}程序切换: {prevProgram} → {currentProgram}";
return;
}
// 检测手动清零(同程序下零件数下降)
if (currentPartCount.HasValue && prevPartCount.HasValue &&
currentPartCount.Value < prevPartCount.Value)
{
analysisType = "MANUAL_RESET";
summary = $"机床{machineName}零件计数手动清零: {prevPartCount} → {currentPartCount}";
return;
}
// 检测零件数增加
if (currentPartCount.HasValue && prevPartCount.HasValue &&
currentPartCount.Value > prevPartCount.Value)
{
decimal delta = currentPartCount.Value - prevPartCount.Value;
analysisType = "PART_COUNT_INCREASE";
summary = $"机床{machineName}新增{delta}个零件({prevPartCount} → {currentPartCount}";
return;
}
// 检测设备离线/告警
if (!string.IsNullOrEmpty(currentStatus) &&
(currentStatus.Equals("OFFLINE", StringComparison.OrdinalIgnoreCase) ||
currentStatus.Equals("ALARM", StringComparison.OrdinalIgnoreCase) ||
currentStatus.Equals("EMERGENCY", StringComparison.OrdinalIgnoreCase)))
{
analysisType = "DEVICE_OFFLINE";
summary = $"机床{machineName}设备离线/告警: {currentStatus}";
needAlert = true;
alertType = "device_offline";
return;
}
// 检测数据异常(关键字段缺失但设备应该在线)
if (string.IsNullOrEmpty(currentProgram) && !string.IsNullOrEmpty(currentStatus) &&
!currentStatus.Equals("OFFLINE", StringComparison.OrdinalIgnoreCase))
{
analysisType = "DATA_ANOMALY";
summary = $"机床{machineName}数据异常: 缺少程序名字段";
needAlert = true;
alertType = "data_anomaly";
return;
}
// 无重大变化
analysisType = "NORMAL_UNCHANGED";
summary = $"机床{machineName}数据无重大变化";
}
/// <summary>
/// 写入单条分析记录到 log_collect_analysis
/// </summary>
private void WriteAnalysis(CncModels.Entity.CollectAnalysis entity)
{
try
{
using (var conn = new MySqlConnection(_logConnStr))
{
conn.Execute(@"INSERT INTO log_collect_analysis
(analysis_time, raw_log_id, collect_address_id, machine_id, analysis_type,
previous_program, current_program, previous_part_count, current_part_count,
part_count_delta, previous_status, current_status, analysis_summary,
analysis_detail, created_at)
VALUES (@AnalysisTime, @RawLogId, @CollectAddressId, @MachineId, @AnalysisType,
@PreviousProgram, @CurrentProgram, @PreviousPartCount, @CurrentPartCount,
@PartCountDelta, @PreviousStatus, @CurrentStatus, @AnalysisSummary,
@AnalysisDetail, NOW())",
new
{
entity.AnalysisTime,
entity.RawLogId,
entity.CollectAddressId,
entity.MachineId,
entity.AnalysisType,
entity.PreviousProgram,
entity.CurrentProgram,
entity.PreviousPartCount,
entity.CurrentPartCount,
entity.PartCountDelta,
entity.PreviousStatus,
entity.CurrentStatus,
entity.AnalysisSummary,
entity.AnalysisDetail
});
}
}
catch (Exception ex)
{
_log.Error($"写入分析记录失败machineId={entity.MachineId}", ex);
}
}
/// <summary>
/// 写入周期汇总到 log_collect_cycle
/// </summary>
private void WriteCycleSummary(CncModels.Entity.CollectCycle entity)
{
try
{
using (var conn = new MySqlConnection(_logConnStr))
{
conn.Execute(@"INSERT INTO log_collect_cycle
(cycle_time, collect_address_id, raw_log_id, end_time, duration_ms,
total_machines, success_count, fail_count, change_distribution,
has_anomaly, cycle_summary, created_at)
VALUES (@CycleTime, @CollectAddressId, @RawLogId, @EndTime, @DurationMs,
@TotalMachines, @SuccessCount, @FailCount, @ChangeDistribution,
@HasAnomaly, @CycleSummary, NOW())",
new
{
entity.CycleTime,
entity.CollectAddressId,
entity.RawLogId,
entity.EndTime,
entity.DurationMs,
entity.TotalMachines,
entity.SuccessCount,
entity.FailCount,
entity.ChangeDistribution,
entity.HasAnomaly,
entity.CycleSummary
});
}
}
catch (Exception ex)
{
_log.Error("写入周期汇总失败", ex);
}
}
/// <summary>
/// 写入告警到 cnc_alert业务库
/// </summary>
private void WriteAlert(string alertType, int machineId, int collectAddressId, string title, string detail)
{
try
{
using (var conn = new MySqlConnection(_businessConnStr))
{
conn.Execute(@"INSERT INTO cnc_alert (alert_type, machine_id, collect_address_id, title, detail, is_resolved, created_at)
VALUES (@AlertType, @MachineId, @AddressId, @Title, @Detail, 0, NOW())",
new
{
AlertType = alertType,
MachineId = machineId,
AddressId = collectAddressId,
Title = title,
Detail = detail
});
}
}
catch (Exception ex)
{
_log.Error($"写入告警失败alertType={alertType}, machineId={machineId}", ex);
}
}
}
}

@ -28,11 +28,12 @@ namespace CncCollector.Core
/// <param name="responseDurationMs">响应耗时(毫秒)</param>
/// <param name="isSuccess">是否采集成功</param>
/// <param name="errorMessage">错误信息(失败时)</param>
public static void WriteBatch(string businessConnStr, string logConnStr,
public static long WriteBatch(string businessConnStr, string logConnStr,
List<CollectRecord> records, string rawJson, int collectAddressId,
DateTime requestTime, long? responseDurationMs, bool isSuccess, string errorMessage, int? statusCode = null)
{
var now = DateTime.Now;
long lastRawLogId = 0;
// 1. 写入原始JSON到日志库
try
@ -54,6 +55,12 @@ namespace CncCollector.Core
ErrorMessage = errorMessage ?? (string)null,
CreatedAt = now
});
// 记录刚插入的 raw_log 的自增ID
try
{
lastRawLogId = conn.ExecuteScalar<long>("SELECT LAST_INSERT_ID();");
}
catch { lastRawLogId = 0; }
}
}
catch (Exception ex)
@ -61,7 +68,7 @@ namespace CncCollector.Core
_log.Error($"写入原始JSON日志失败地址ID={collectAddressId}", ex);
}
if (!isSuccess || records == null || records.Count == 0) return;
if (!isSuccess || records == null || records.Count == 0) return lastRawLogId;
// 2. 批量写入采集结构化记录到业务库
try
@ -162,6 +169,7 @@ namespace CncCollector.Core
{
_log.Error($"批量写入采集记录失败地址ID={collectAddressId}", ex);
}
return lastRawLogId;
}
/// <summary>

@ -29,6 +29,7 @@ namespace CncCollector.Core
private readonly CollectAddress _address;
private readonly CollectorConfig _config;
private readonly ProductionTracker _tracker;
private readonly AnalysisEngine _analysisEngine;
private readonly string _businessConnStr;
private readonly string _logConnStr;
private Thread _thread;
@ -65,11 +66,12 @@ namespace CncCollector.Core
/// <param name="businessConnStr">业务库连接字符串</param>
/// <param name="logConnStr">日志库连接字符串</param>
public CollectWorker(CollectAddress address, CollectorConfig config, ProductionTracker tracker,
string businessConnStr, string logConnStr)
AnalysisEngine analysisEngine, string businessConnStr, string logConnStr)
{
_address = address;
_config = config;
_tracker = tracker;
_analysisEngine = analysisEngine;
_businessConnStr = businessConnStr;
_logConnStr = logConnStr;
}
@ -400,9 +402,15 @@ namespace CncCollector.Core
}
// 4. 批量写入
CollectRecordWriter.WriteBatch(_businessConnStr, _logConnStr, records, rawJson,
long rawLogId = CollectRecordWriter.WriteBatch(_businessConnStr, _logConnStr, records, rawJson,
_address.Id, requestTime, durationMs, true, null, statusCode);
// 采集分析:将分析任务委托给 AnalysisEngine
if (rawLogId > 0 && records != null && records.Count > 0 && _analysisEngine != null)
{
_analysisEngine.AnalyzeAndRecord(rawLogId, _address.Id, _address.Name, records, machineDict, requestTime, durationMs);
}
_log.Info($"采集完成: {_address.Name} → {records.Count}台设备, {durationMs}ms");
}

@ -21,6 +21,8 @@ namespace CncCollector.Core
private readonly CollectorConfig _config;
private readonly ConcurrentDictionary<int, CollectWorker> _workers = new ConcurrentDictionary<int, CollectWorker>();
private readonly ProductionTracker _tracker;
// 复用的分析引擎实例(简单实现:按地址注入,避免跨线程问题)
private readonly AnalysisEngine _analysisEngine;
private readonly DailySummaryJob _dailySummary;
private Timer _heartbeatTimer;
private Timer _configPollTimer;
@ -52,6 +54,8 @@ namespace CncCollector.Core
_config = config;
_tracker = new ProductionTracker(config.BusinessConnection);
_dailySummary = new DailySummaryJob(config.BusinessConnection);
// 初始化分析引擎(与业务库和日志库同源,后续按需调整)
_analysisEngine = new AnalysisEngine(config.BusinessConnection, config.LogConnection);
}
/// <summary>
@ -203,17 +207,17 @@ namespace CncCollector.Core
}
// 启动新增的地址
foreach (var addr in addresses)
{
if (!_workers.ContainsKey(addr.Id))
foreach (var addr in addresses)
{
var worker = new CollectWorker(addr, _config, _tracker,
_config.BusinessConnection, _config.LogConnection);
worker.Start();
_workers[addr.Id] = worker;
if (!_workers.ContainsKey(addr.Id))
{
var worker = new CollectWorker(addr, _config, _tracker,
_analysisEngine, _config.BusinessConnection, _config.LogConnection);
worker.Start();
_workers[addr.Id] = worker;
_log.Info($"已启动采集地址: {addr.Name}URL={addr.Url}, 间隔={addr.CollectInterval}秒)");
}
}
}
}
catch (Exception ex)
{

@ -0,0 +1,15 @@
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集分析详情(基于 CollectAnalysisListItem 的扩展字段)
/// </summary>
public class CollectAnalysisDetail : CollectAnalysisListItem
{
public decimal? PreviousPartCount { get; set; }
public decimal? CurrentPartCount { get; set; }
public string PreviousStatus { get; set; }
public string CurrentStatus { get; set; }
public string AnalysisDetail { get; set; }
public long RawLogId { get; set; }
}
}

@ -0,0 +1,19 @@
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集分析列表项
/// </summary>
public class CollectAnalysisListItem
{
public long Id { get; set; }
public string AnalysisTime { get; set; }
public int CollectAddressId { get; set; }
public int MachineId { get; set; }
public string MachineName { get; set; }
public string AnalysisType { get; set; }
public string PreviousProgram { get; set; }
public string CurrentProgram { get; set; }
public decimal? PartCountDelta { get; set; }
public string AnalysisSummary { get; set; }
}
}

@ -0,0 +1,17 @@
using System;
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集分析列表查询条件(分页)
/// </summary>
public class CollectAnalysisQuery : PagedQuery
{
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public int? CollectAddressId { get; set; }
public int? MachineId { get; set; }
public string AnalysisType { get; set; }
public string ProgramName { get; set; }
}
}

@ -0,0 +1,19 @@
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集周期列表项
/// </summary>
public class CollectCycleListItem
{
public long Id { get; set; }
public string CycleTime { get; set; }
public int CollectAddressId { get; set; }
public string AddressName { get; set; }
public int TotalMachines { get; set; }
public int SuccessCount { get; set; }
public int FailCount { get; set; }
public int HasAnomaly { get; set; }
public string ChangeDistribution { get; set; }
public string CycleSummary { get; set; }
}
}

@ -0,0 +1,15 @@
using System;
namespace CncModels.Dto.CollectLog
{
/// <summary>
/// 采集周期查询条件(分页)
/// </summary>
public class CollectCycleQuery : PagedQuery
{
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public int? CollectAddressId { get; set; }
public int? HasAnomaly { get; set; }
}
}

@ -0,0 +1,58 @@
using System;
namespace CncModels.Entity
{
/// <summary>
/// 采集分析记录实体(日志库 log_collect_analysis
/// </summary>
public class CollectAnalysis
{
/// <summary>自增ID</summary>
public long Id { get; set; }
/// <summary>分析时间</summary>
public DateTime AnalysisTime { get; set; }
/// <summary>原始日志ID</summary>
public long RawLogId { get; set; }
/// <summary>采集地址ID</summary>
public int CollectAddressId { get; set; }
/// <summary>机器ID</summary>
public int MachineId { get; set; }
/// <summary>分析类型</summary>
public string AnalysisType { get; set; }
/// <summary>前一程序名</summary>
public string PreviousProgram { get; set; }
/// <summary>当前程序名</summary>
public string CurrentProgram { get; set; }
/// <summary>前一阶段产出数量</summary>
public decimal? PreviousPartCount { get; set; }
/// <summary>当前阶段产出数量</summary>
public decimal? CurrentPartCount { get; set; }
/// <summary>产出变化量</summary>
public decimal? PartCountDelta { get; set; }
/// <summary>前一状态</summary>
public string PreviousStatus { get; set; }
/// <summary>当前状态</summary>
public string CurrentStatus { get; set; }
/// <summary>分析概要</summary>
public string AnalysisSummary { get; set; }
/// <summary>分析细节JSON字符串</summary>
public string AnalysisDetail { get; set; }
/// <summary>创建时间</summary>
public DateTime CreatedAt { get; set; }
}
}

@ -0,0 +1,49 @@
using System;
namespace CncModels.Entity
{
/// <summary>
/// 采集分析周期实体(日志库 log_collect_cycle
/// </summary>
public class CollectCycle
{
/// <summary>自增ID</summary>
public long Id { get; set; }
/// <summary>周期时间</summary>
public DateTime CycleTime { get; set; }
/// <summary>采集地址ID</summary>
public int CollectAddressId { get; set; }
/// <summary>原始日志ID</summary>
public long RawLogId { get; set; }
/// <summary>结束时间</summary>
public DateTime? EndTime { get; set; }
/// <summary>周期持续时长(毫秒)</summary>
public int? DurationMs { get; set; }
/// <summary>总机器数</summary>
public int TotalMachines { get; set; }
/// <summary>成功计数</summary>
public int SuccessCount { get; set; }
/// <summary>失败计数</summary>
public int FailCount { get; set; }
/// <summary>分布变化JSON</summary>
public string ChangeDistribution { get; set; }
/// <summary>是否存在异常0/1</summary>
public int HasAnomaly { get; set; }
/// <summary>周期概要</summary>
public string CycleSummary { get; set; }
/// <summary>创建时间</summary>
public DateTime CreatedAt { get; set; }
}
}

@ -0,0 +1,18 @@
namespace CncModels.Enum
{
/// <summary>
/// 采集分析的分析类型枚举(以字符串常量形式提供)
/// </summary>
public static class AnalysisType
{
public const string NORMAL_UNCHANGED = "NORMAL_UNCHANGED";
public const string PART_COUNT_INCREASE = "PART_COUNT_INCREASE";
public const string PROGRAM_SWITCH = "PROGRAM_SWITCH";
public const string MANUAL_RESET = "MANUAL_RESET";
public const string DEVICE_ONLINE = "DEVICE_ONLINE";
public const string DEVICE_OFFLINE = "DEVICE_OFFLINE";
public const string NEW_DEVICE_FOUND = "NEW_DEVICE_FOUND";
public const string DATA_ANOMALY = "DATA_ANOMALY";
public const string COLLECTION_FAILED = "COLLECTION_FAILED";
}
}

@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dapper;
using MySqlConnector;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
using CncRepository.Base;
using CncRepository.Interface;
namespace CncRepository.Impl.Log
{
/// <summary>
/// 采集分析仓储实现(日志库 - log_collect_analysis
/// </summary>
public class CollectAnalysisRepository : LogRepository, ICollectAnalysisRepository
{
public CollectAnalysisRepository(string connectionString) : base(connectionString) { }
public PagedResult<CollectAnalysisListItem> GetAnalysisList(CollectAnalysisQuery query)
{
using (var conn = CreateConnection())
{
var whereParts = new List<string> { "1=1" };
var p = new DynamicParameters();
if (query.StartDate.HasValue)
{
whereParts.Add("a.analysis_time >= @StartDate");
p.Add("StartDate", query.StartDate);
}
if (query.EndDate.HasValue)
{
whereParts.Add("a.analysis_time <= @EndDate");
p.Add("EndDate", query.EndDate);
}
if (query.CollectAddressId.HasValue)
{
whereParts.Add("a.collect_address_id = @CollectAddressId");
p.Add("CollectAddressId", query.CollectAddressId);
}
if (query.MachineId.HasValue)
{
whereParts.Add("a.machine_id = @MachineId");
p.Add("MachineId", query.MachineId);
}
if (!string.IsNullOrEmpty(query.AnalysisType))
{
whereParts.Add("a.analysis_type = @AnalysisType");
p.Add("AnalysisType", query.AnalysisType);
}
if (!string.IsNullOrEmpty(query.ProgramName))
{
whereParts.Add("a.current_program LIKE CONCAT('%', @ProgramName, '%')");
p.Add("ProgramName", query.ProgramName);
}
var whereSql = string.Join(" AND ", whereParts);
// 统计总条数
var total = conn.ExecuteScalar<int>(
$"SELECT COUNT(1) FROM log_collect_analysis a WHERE {whereSql}", p);
// 分页查询(左连机床表获取名称)
var dataSql = $@"SELECT
a.id AS Id,
DATE_FORMAT(a.analysis_time, '%Y-%m-%d %H:%i:%s') AS AnalysisTime,
a.collect_address_id AS CollectAddressId,
a.machine_id AS MachineId,
m.name AS MachineName,
a.analysis_type AS AnalysisType,
a.previous_program AS PreviousProgram,
a.current_program AS CurrentProgram,
a.part_count_delta AS PartCountDelta,
a.analysis_summary AS AnalysisSummary
FROM log_collect_analysis a
LEFT JOIN cnc_business.cnc_machine m ON a.machine_id = m.id
WHERE {whereSql}
ORDER BY a.analysis_time DESC
LIMIT @PageSize OFFSET @Offset";
var items = conn.Query<CollectAnalysisListItem>(dataSql,
new { PageSize = query.PageSize, Offset = query.Offset }).AsList();
return new PagedResult<CollectAnalysisListItem>
{
Items = items,
Total = total,
Page = query.Page,
PageSize = query.PageSize
};
}
}
public CollectAnalysisDetail GetAnalysisDetail(long id)
{
using (var conn = CreateConnection())
{
var sql = @"SELECT
a.id AS Id,
DATE_FORMAT(a.analysis_time, '%Y-%m-%d %H:%i:%s') AS AnalysisTime,
a.collect_address_id AS CollectAddressId,
a.machine_id AS MachineId,
m.name AS MachineName,
a.analysis_type AS AnalysisType,
a.previous_program AS PreviousProgram,
a.current_program AS CurrentProgram,
a.part_count_delta AS PartCountDelta,
a.previous_part_count AS PreviousPartCount,
a.current_part_count AS CurrentPartCount,
a.previous_status AS PreviousStatus,
a.current_status AS CurrentStatus,
a.analysis_summary AS AnalysisSummary,
a.analysis_detail AS AnalysisDetail,
a.raw_log_id AS RawLogId
FROM log_collect_analysis a
LEFT JOIN cnc_business.cnc_machine m ON a.machine_id = m.id
WHERE a.id = @Id";
return conn.QueryFirstOrDefault<CollectAnalysisDetail>(sql, new { Id = id });
}
}
public List<CollectAnalysisListItem> GetAnalysisByRawLogId(long rawLogId)
{
using (var conn = CreateConnection())
{
var sql = @"SELECT
a.id AS Id,
DATE_FORMAT(a.analysis_time, '%Y-%m-%d %H:%i:%s') AS AnalysisTime,
a.collect_address_id AS CollectAddressId,
a.machine_id AS MachineId,
m.name AS MachineName,
a.analysis_type AS AnalysisType,
a.previous_program AS PreviousProgram,
a.current_program AS CurrentProgram,
a.part_count_delta AS PartCountDelta,
a.analysis_summary AS AnalysisSummary
FROM log_collect_analysis a
LEFT JOIN cnc_business.cnc_machine m ON a.machine_id = m.id
WHERE a.raw_log_id = @RawLogId
ORDER BY a.analysis_time DESC";
return conn.Query<CollectAnalysisListItem>(sql, new { RawLogId = rawLogId }).AsList();
}
}
public long Create(CollectAnalysis entity)
{
using (var conn = CreateConnection())
{
var sql = @"INSERT INTO log_collect_analysis
(analysis_time, raw_log_id, collect_address_id, machine_id, analysis_type,
previous_program, current_program, previous_part_count, current_part_count,
part_count_delta, previous_status, current_status, analysis_summary,
analysis_detail, created_at)
VALUES (@AnalysisTime, @RawLogId, @CollectAddressId, @MachineId, @AnalysisType,
@PreviousProgram, @CurrentProgram, @PreviousPartCount, @CurrentPartCount,
@PartCountDelta, @PreviousStatus, @CurrentStatus, @AnalysisSummary,
@AnalysisDetail, NOW());
SELECT LAST_INSERT_ID();";
return conn.ExecuteScalar<long>(sql, new
{
entity.AnalysisTime,
entity.RawLogId,
entity.CollectAddressId,
entity.MachineId,
entity.AnalysisType,
entity.PreviousProgram,
entity.CurrentProgram,
entity.PreviousPartCount,
entity.CurrentPartCount,
entity.PartCountDelta,
entity.PreviousStatus,
entity.CurrentStatus,
entity.AnalysisSummary,
entity.AnalysisDetail
});
}
}
public int DeleteBeforeDate(DateTime date)
{
using (var conn = CreateConnection())
{
return conn.Execute(
"DELETE FROM log_collect_analysis WHERE analysis_time < @Date",
new { Date = date });
}
}
}
}

@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dapper;
using MySqlConnector;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
using CncRepository.Base;
using CncRepository.Interface;
namespace CncRepository.Impl.Log
{
/// <summary>
/// 采集周期仓储实现(日志库 - log_collect_cycle
/// </summary>
public class CollectCycleRepository : LogRepository, ICollectCycleRepository
{
public CollectCycleRepository(string connectionString) : base(connectionString) { }
public PagedResult<CollectCycleListItem> GetCycleList(CollectCycleQuery query)
{
using (var conn = CreateConnection())
{
var whereParts = new List<string> { "1=1" };
var p = new DynamicParameters();
if (query.StartDate.HasValue)
{
whereParts.Add("c.cycle_time >= @StartDate");
p.Add("StartDate", query.StartDate);
}
if (query.EndDate.HasValue)
{
whereParts.Add("c.cycle_time <= @EndDate");
p.Add("EndDate", query.EndDate);
}
if (query.CollectAddressId.HasValue)
{
whereParts.Add("c.collect_address_id = @CollectAddressId");
p.Add("CollectAddressId", query.CollectAddressId);
}
if (query.HasAnomaly.HasValue)
{
whereParts.Add("c.has_anomaly = @HasAnomaly");
p.Add("HasAnomaly", query.HasAnomaly);
}
var whereSql = string.Join(" AND ", whereParts);
var total = conn.ExecuteScalar<int>(
$"SELECT COUNT(1) FROM log_collect_cycle c WHERE {whereSql}", p);
var dataSql = $@"SELECT
c.id AS Id,
DATE_FORMAT(c.cycle_time, '%Y-%m-%d %H:%i:%s') AS CycleTime,
c.collect_address_id AS CollectAddressId,
ca.address_name AS AddressName,
c.total_machines AS TotalMachines,
c.success_count AS SuccessCount,
c.fail_count AS FailCount,
c.has_anomaly AS HasAnomaly,
c.change_distribution AS ChangeDistribution,
c.cycle_summary AS CycleSummary
FROM log_collect_cycle c
LEFT JOIN cnc_business.cnc_collect_address ca ON c.collect_address_id = ca.id
WHERE {whereSql}
ORDER BY c.cycle_time DESC
LIMIT @PageSize OFFSET @Offset";
var items = conn.Query<CollectCycleListItem>(dataSql,
new { PageSize = query.PageSize, Offset = query.Offset }).AsList();
return new PagedResult<CollectCycleListItem>
{
Items = items,
Total = total,
Page = query.Page,
PageSize = query.PageSize
};
}
}
public long Create(CollectCycle entity)
{
using (var conn = CreateConnection())
{
var sql = @"INSERT INTO log_collect_cycle
(cycle_time, collect_address_id, raw_log_id, end_time, duration_ms,
total_machines, success_count, fail_count, change_distribution,
has_anomaly, cycle_summary, created_at)
VALUES (@CycleTime, @CollectAddressId, @RawLogId, @EndTime, @DurationMs,
@TotalMachines, @SuccessCount, @FailCount, @ChangeDistribution,
@HasAnomaly, @CycleSummary, NOW());
SELECT LAST_INSERT_ID();";
return conn.ExecuteScalar<long>(sql, new
{
entity.CycleTime,
entity.CollectAddressId,
entity.RawLogId,
entity.EndTime,
entity.DurationMs,
entity.TotalMachines,
entity.SuccessCount,
entity.FailCount,
entity.ChangeDistribution,
entity.HasAnomaly,
entity.CycleSummary
});
}
}
public int DeleteBeforeDate(DateTime date)
{
using (var conn = CreateConnection())
{
return conn.Execute(
"DELETE FROM log_collect_cycle WHERE cycle_time < @Date",
new { Date = date });
}
}
}
}

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
namespace CncRepository.Interface
{
/// <summary>
/// 采集分析仓储接口
/// </summary>
public interface ICollectAnalysisRepository
{
PagedResult<CollectAnalysisListItem> GetAnalysisList(CollectAnalysisQuery query);
CollectAnalysisDetail GetAnalysisDetail(long id);
List<CollectAnalysisListItem> GetAnalysisByRawLogId(long rawLogId);
long Create(CollectAnalysis entity);
int DeleteBeforeDate(DateTime date);
}
}

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
namespace CncRepository.Interface
{
/// <summary>
/// 采集周期仓储接口
/// </summary>
public interface ICollectCycleRepository
{
PagedResult<CollectCycleListItem> GetCycleList(CollectCycleQuery query);
long Create(CollectCycle entity);
int DeleteBeforeDate(DateTime date);
}
}

@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Constants;
using CncService.Interface;
using CncRepository.Interface;
namespace CncService.Impl
{
/// <summary>
/// 采集日志相关的业务实现
/// </summary>
public class CollectLogService : ICollectLogService
{
private readonly ICollectAnalysisRepository _analysisRepository;
private readonly ICollectCycleRepository _cycleRepository;
public CollectLogService(ICollectAnalysisRepository analysisRepository, ICollectCycleRepository cycleRepository)
{
_analysisRepository = analysisRepository ?? throw new ArgumentNullException(nameof(analysisRepository));
_cycleRepository = cycleRepository ?? throw new ArgumentNullException(nameof(cycleRepository));
}
public PagedResult<CollectAnalysisListItem> GetAnalysisList(CollectAnalysisQuery query)
{
if (query == null) throw new BusinessException(ErrorCode.BadRequest, "查询参数不能为空");
return _analysisRepository.GetAnalysisList(query);
}
public CollectAnalysisDetail GetAnalysisDetail(long id)
{
var detail = _analysisRepository.GetAnalysisDetail(id);
if (detail == null) throw new BusinessException(ErrorCode.NotFound, "采集分析记录不存在");
return detail;
}
public List<CollectAnalysisListItem> GetAnalysisByRawLogId(long rawLogId)
{
return _analysisRepository.GetAnalysisByRawLogId(rawLogId);
}
public PagedResult<CollectCycleListItem> GetCycleList(CollectCycleQuery query)
{
if (query == null) throw new BusinessException(ErrorCode.BadRequest, "查询参数不能为空");
return _cycleRepository.GetCycleList(query);
}
}
}

@ -0,0 +1,21 @@
using System.Collections.Generic;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
namespace CncService.Interface
{
public interface ICollectLogService
{
/// <summary>分页查询采集分析日志</summary>
PagedResult<CollectAnalysisListItem> GetAnalysisList(CollectAnalysisQuery query);
/// <summary>获取单条采集分析日志的详情</summary>
CollectAnalysisDetail GetAnalysisDetail(long id);
/// <summary>根据原始日志ID查找相关联的分析记录</summary>
List<CollectAnalysisListItem> GetAnalysisByRawLogId(long rawLogId);
/// <summary>分页查询采集周期信息</summary>
PagedResult<CollectCycleListItem> GetCycleList(CollectCycleQuery query);
}
}

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Web.Http;
using CncModels.Dto;
using CncModels.Dto.CollectLog;
using CncModels.Entity;
using CncService.Interface;
using CncRepository.Interface;
using CncWebApi.Infrastructure;
using System.Web.Http.Description;
namespace CncWebApi.Controllers
{
/// <summary>
/// 采集日志管理控制器
/// </summary>
[RoutePrefix("api/admin/collect-log")]
[JwtAuthFilter]
public class CollectLogController : ApiController
{
private readonly ICollectLogService _collectLogService;
private readonly ICollectRawRepository _rawRepository;
public CollectLogController(ICollectLogService collectLogService, ICollectRawRepository rawRepository)
{
_collectLogService = collectLogService ?? throw new ArgumentNullException(nameof(collectLogService));
_rawRepository = rawRepository ?? throw new ArgumentNullException(nameof(rawRepository));
}
/// <summary>分页查询采集分析记录</summary>
[HttpGet]
[Route("analysis")]
[ResponseType(typeof(ApiResponse<PagedResult<CollectAnalysisListItem>>))]
public IHttpActionResult GetAnalysisList([FromUri] CollectAnalysisQuery query)
{
if (query == null) query = new CollectAnalysisQuery();
var result = _collectLogService.GetAnalysisList(query);
return Ok(ApiResponse<PagedResult<CollectAnalysisListItem>>.Success(result));
}
/// <summary>获取采集分析详情</summary>
[HttpGet]
[Route("analysis/{id:long}")]
[ResponseType(typeof(ApiResponse<CollectAnalysisDetail>))]
public IHttpActionResult GetAnalysisDetail(long id)
{
var detail = _collectLogService.GetAnalysisDetail(id);
return Ok(ApiResponse<CollectAnalysisDetail>.Success(detail));
}
/// <summary>根据原始日志ID查询关联的分析记录</summary>
[HttpGet]
[Route("analysis/by-raw/{rawLogId:long}")]
[ResponseType(typeof(ApiResponse<List<CollectAnalysisListItem>>))]
public IHttpActionResult GetAnalysisByRawLogId(long rawLogId)
{
var list = _collectLogService.GetAnalysisByRawLogId(rawLogId);
return Ok(ApiResponse<List<CollectAnalysisListItem>>.Success(list));
}
/// <summary>分页查询采集周期</summary>
[HttpGet]
[Route("cycle")]
[ResponseType(typeof(ApiResponse<PagedResult<CollectCycleListItem>>))]
public IHttpActionResult GetCycleList([FromUri] CollectCycleQuery query)
{
if (query == null) query = new CollectCycleQuery();
var result = _collectLogService.GetCycleList(query);
return Ok(ApiResponse<PagedResult<CollectCycleListItem>>.Success(result));
}
/// <summary>查询原始采集日志</summary>
[HttpGet]
[Route("raw")]
[ResponseType(typeof(ApiResponse<PagedResult<CollectRaw>>))]
public IHttpActionResult GetRawList([FromUri] int? collectAddressId, [FromUri] int page = 1, [FromUri] int pageSize = 20)
{
var result = _rawRepository.GetByAddressId(collectAddressId ?? 0, page, pageSize);
return Ok(ApiResponse<PagedResult<CollectRaw>>.Success(result));
}
}
}

@ -78,6 +78,10 @@ namespace CncWebApi.Infrastructure
ResolveCollectAddressService());
if (serviceType == typeof(Controllers.HealthController))
return new Controllers.HealthController();
if (serviceType == typeof(Controllers.CollectLogController))
return new Controllers.CollectLogController(
ResolveCollectLogService(),
new CncRepository.Impl.Log.CollectRawRepository(_logConn));
return null;
}
@ -184,6 +188,13 @@ namespace CncWebApi.Infrastructure
return new CncRepository.Impl.ProductionAdjustmentRepository(_businessConn);
}
private ICollectLogService ResolveCollectLogService()
{
return new CncService.Impl.CollectLogService(
new CncRepository.Impl.Log.CollectAnalysisRepository(_logConn),
new CncRepository.Impl.Log.CollectCycleRepository(_logConn));
}
#endregion
}
}

@ -39,8 +39,8 @@ namespace CncCollector.Tests
string businessConn = "Server=.;Database=NonExistingDb;User Id=invalid;Password=invalid;";
string logConn = "Server=.;Database=NonExistingLogDb;User Id=invalid;Password=invalid;";
// 允许 tracker 为 null因为在无效 URL 的测试路径下通常不会进入需要 Tracker 的分支
_worker = new CollectWorker(_address, _config, null, businessConn, logConn);
// 允许 tracker 和 analysisEngine 为 null因为在无效 URL 的测试路径下通常不会进入需要它们的分支
_worker = new CollectWorker(_address, _config, null, null, businessConn, logConn);
}
public void Dispose()

Loading…
Cancel
Save