|
|
# CNC机床数据采集系统 - 采集服务设计文档
|
|
|
|
|
|
> 最后更新:2026-04-30
|
|
|
> 状态:设计中
|
|
|
|
|
|
---
|
|
|
|
|
|
## 一、概述
|
|
|
|
|
|
采集服务(CncCollector)是一个独立 Windows Service,负责定时从采集地址HTTP拉取JSON数据,通过品牌字段映射解析为结构化数据,实时更新机床状态,追踪产量分段,并执行日终汇总。
|
|
|
|
|
|
### 1.1 核心职责
|
|
|
|
|
|
| 职责 | 说明 |
|
|
|
|------|------|
|
|
|
| HTTP数据拉取 | 按采集地址配置的间隔,定时GET拉取JSON |
|
|
|
| 字段映射解析 | 用品牌模板的映射规则,从JSON中提取标准字段 |
|
|
|
| 实时状态更新 | 更新机床表的在线状态、采集状态、程序名、零件数等 |
|
|
|
| 产量分段追踪 | 检测NC程序名切换、零件数手动清零,自动结账/开段 |
|
|
|
| 日终汇总 | 按自然天汇总产量到机床+程序级别、工人级别 |
|
|
|
| 原始数据存档 | 存原始JSON到日志库,支持事后重解析 |
|
|
|
| 心跳上报 | 定时写心跳记录,供管理后台判断服务是否在线 |
|
|
|
| 告警生成 | 采集失败、未知设备、字段缺失等异常生成告警记录 |
|
|
|
|
|
|
### 1.2 技术栈
|
|
|
|
|
|
| 项 | 选型 | 说明 |
|
|
|
|----|------|------|
|
|
|
| 框架 | .NET Framework 4.7.2 Windows Service | 与Web API同框架 |
|
|
|
| ORM | Dapper | 复用现有CncRepository层 |
|
|
|
| HTTP客户端 | System.Net.Http.HttpClient | 拉取采集地址JSON |
|
|
|
| JSON解析 | Newtonsoft.Json 12.0.3 | 与Web API版本一致 |
|
|
|
| 日志 | log4net | 文件日志 + DB日志双写 |
|
|
|
| 数据库 | MariaDB | 业务库 + 日志库双库 |
|
|
|
| 管理API | HttpListener(轻量自托管) | 5800端口,独立于IIS |
|
|
|
|
|
|
### 1.3 与现有系统的关系
|
|
|
|
|
|
```
|
|
|
┌─────────────────────────────────────────────────┐
|
|
|
│ Windows Server │
|
|
|
│ │
|
|
|
│ ┌──────────────┐ ┌───────────────────────┐ │
|
|
|
│ │ IIS │ │ CncCollector │ │
|
|
|
│ │ CncWebApi │ │ (Windows Service) │ │
|
|
|
│ │ :80 │ │ 管理API :5800 │ │
|
|
|
│ │ 管理后台/大屏 │ │ 采集循环+Ping+汇总 │ │
|
|
|
│ └──────┬───────┘ └───────────┬───────────┘ │
|
|
|
│ │ │ │
|
|
|
│ └──────────┬─────────────┘ │
|
|
|
│ ▼ │
|
|
|
│ ┌────────────────────┐ │
|
|
|
│ │ MariaDB │ │
|
|
|
│ │ cnc_business │ │
|
|
|
│ │ cnc_log │ │
|
|
|
│ └────────────────────┘ │
|
|
|
└─────────────────────────────────────────────────┘
|
|
|
```
|
|
|
|
|
|
- CncCollector 复用现有的 `CncModels`、`CncRepository` 层(直接引用项目)
|
|
|
- 不复用 `CncService` 层(采集服务有独立的业务逻辑,不依赖Web API的Service)
|
|
|
- 通过数据库共享数据,不直接调用Web API
|
|
|
|
|
|
---
|
|
|
|
|
|
## 二、项目结构
|
|
|
|
|
|
### 2.1 新建项目
|
|
|
|
|
|
在解决方案中新增 `CncCollector` 项目:
|
|
|
|
|
|
```
|
|
|
src/CncCollector/
|
|
|
├── CncCollector.csproj ← .NET Framework 4.7.2 类库
|
|
|
├── ServiceEntry.cs ← Windows Service 入口(ServiceBase子类)
|
|
|
├── ServiceInstaller.cs ← 安装器(Installer子类,installutil用)
|
|
|
├── Program.cs ← 主入口(Main)
|
|
|
│
|
|
|
├── Engine/
|
|
|
│ ├── CollectorEngine.cs ← 引擎主控(启停所有任务、生命周期管理)
|
|
|
│ ├── CollectorTask.cs ← 单个采集地址的采集循环
|
|
|
│ └── PingTask.cs ← 单个采集地址的Ping循环
|
|
|
│
|
|
|
├── Parser/
|
|
|
│ ├── FieldMapper.cs ← 字段映射解析器(纯函数,输入JSON+映射规则→标准字段字典)
|
|
|
│ └── ValueConverter.cs ← 数值转换(去.00000尾缀,string/number类型转换)
|
|
|
│
|
|
|
├── Tracker/
|
|
|
│ └── ProductionTracker.cs ← 产量分段追踪(检测程序切换/手动清零→结账/开段)
|
|
|
│
|
|
|
├── Jobs/
|
|
|
│ └── DailySummaryJob.cs ← 日终汇总任务(关闭活跃段→合并产量→工人汇总→日状态)
|
|
|
│
|
|
|
├── Config/
|
|
|
│ ├── ConfigProvider.cs ← 配置热更新(DB轮询+HTTP通知双保险)
|
|
|
│ └── CollectorConfig.cs ← 配置快照数据结构
|
|
|
│
|
|
|
├── Heartbeat/
|
|
|
│ └── HeartbeatReporter.cs ← 心跳上报(定时写log_collector_heartbeat)
|
|
|
│
|
|
|
├── Api/
|
|
|
│ ├── ManagementServer.cs ← 轻量HTTP管理API(HttpListener自托管)
|
|
|
│ └── ManagementController.cs ← API端点实现(状态/重载/启停/汇总)
|
|
|
│
|
|
|
├── Logging/
|
|
|
│ └── DualLogger.cs ← 双写日志(文件log4net + DB log_system)
|
|
|
│
|
|
|
└── App.config ← 连接串、端口、API Key、log4net配置
|
|
|
```
|
|
|
|
|
|
### 2.2 项目引用
|
|
|
|
|
|
```
|
|
|
CncCollector → CncRepository → CncModels
|
|
|
↘
|
|
|
(不引用 CncService、CncWebApi)
|
|
|
```
|
|
|
|
|
|
CncCollector 直接使用 Repository 层操作数据库,不经过 Service 层。原因:
|
|
|
- 采集服务有大量独立的业务逻辑(字段映射、产量分段、汇总计算),与Web API的CRUD Service职责不同
|
|
|
- 避免与Web API产生耦合(Service层可能被修改影响采集服务)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 三、核心流程设计
|
|
|
|
|
|
### 3.1 采集地址的数据模型
|
|
|
|
|
|
```
|
|
|
采集地址(cnc_collect_address)
|
|
|
├── URL:HTTP端点地址(如 http://192.168.1.101/)
|
|
|
├── 品牌:决定字段映射规则
|
|
|
├── 采集间隔:本地址独立配置
|
|
|
├── 关联机床:该地址下要采的机床列表
|
|
|
└── 启停状态:is_enabled
|
|
|
|
|
|
一次HTTP GET 返回:
|
|
|
[ ← JSON数组
|
|
|
{ device:"fanake_1.8", desc:"西-1.8", tags:[{id:"Tag5",value:"1566.NC"},...] },
|
|
|
{ device:"fanake_1.9", desc:"西-1.9", tags:[{id:"Tag5",value:"O1"},...] },
|
|
|
...
|
|
|
]
|
|
|
|
|
|
device值 匹配 cnc_machine.device_code → 定位具体机床
|
|
|
tags数组 通过品牌字段映射规则 → 提取标准字段
|
|
|
```
|
|
|
|
|
|
### 3.2 双循环模型(Ping + 采集)
|
|
|
|
|
|
每个启用的采集地址启动两个独立的 Task:
|
|
|
|
|
|
#### 3.2.1 Ping循环
|
|
|
|
|
|
```
|
|
|
PingTask(address):
|
|
|
while(服务运行中 && address启用):
|
|
|
try:
|
|
|
success = Ping(address.Url) // ICMP Ping采集地址
|
|
|
catch:
|
|
|
success = false
|
|
|
|
|
|
更新该地址下所有机床:
|
|
|
is_online = success ? 1 : 0
|
|
|
last_ping_time = NOW()
|
|
|
|
|
|
sleep(ping_interval) // 全局配置,默认60秒
|
|
|
```
|
|
|
|
|
|
**关键点**:
|
|
|
- Ping结果仅用于管理后台/大屏显示机床在线状态
|
|
|
- Ping不通**不影响**采集逻辑——采集循环照常HTTP拉取
|
|
|
- 机床表的 `ip_address` 字段不参与Ping逻辑,仅信息记录
|
|
|
- Ping是针对采集地址URL(HTTP端点),不是单台机床
|
|
|
|
|
|
#### 3.2.2 采集循环
|
|
|
|
|
|
```
|
|
|
CollectTask(address):
|
|
|
while(服务运行中 && address启用):
|
|
|
roundStart = NOW()
|
|
|
|
|
|
// 检查重载/停止请求(本轮开始前)
|
|
|
if(停止请求): 安全退出
|
|
|
if(重载请求): 执行重载后继续
|
|
|
|
|
|
try:
|
|
|
// 1. HTTP拉取
|
|
|
responseJson, duration = HTTP GET address.Url
|
|
|
|
|
|
// 2. 原始JSON存档(日志库,先于解析,防止崩溃丢数据)
|
|
|
写入 log_collect_raw(is_success=1, raw_json=responseJson)
|
|
|
|
|
|
// 3. 按device逐台解析
|
|
|
devices = 解析JSON数组
|
|
|
for each device in devices:
|
|
|
处理单个设备(device, address)
|
|
|
|
|
|
// 4. 更新采集地址状态
|
|
|
address.last_collect_time = NOW()
|
|
|
address.last_collect_status = "success"
|
|
|
address.fail_count = 0
|
|
|
|
|
|
catch(异常 ex):
|
|
|
处理采集失败(address, ex)
|
|
|
|
|
|
// 本轮完成,检查挂起的停止/重载请求
|
|
|
if(停止请求): 安全退出
|
|
|
if(重载请求): 执行重载
|
|
|
|
|
|
// 计算本轮耗时,sleep剩余间隔
|
|
|
elapsed = NOW() - roundStart
|
|
|
sleep(max(0, collect_interval - elapsed))
|
|
|
```
|
|
|
|
|
|
**关键点**:
|
|
|
- 原始JSON先写入日志库再解析,防止解析过程中服务崩溃导致数据丢失
|
|
|
- 每轮采集结束才检查停止/重载请求,保证不会中断中间状态
|
|
|
- 采集间隔扣除本轮耗时,保证频率稳定
|
|
|
|
|
|
### 3.3 处理单个设备
|
|
|
|
|
|
```
|
|
|
处理单个设备(deviceObj, address):
|
|
|
// 1. 匹配机床
|
|
|
deviceCode = deviceObj[brand.deviceField] // 默认取 device 字段
|
|
|
machine = 按 device_code 查机床表
|
|
|
|
|
|
if(machine == null):
|
|
|
// 未知设备 → 告警(带完整JSON)
|
|
|
创建告警(alert_type="unknown_device", detail=deviceObj完整JSON)
|
|
|
return
|
|
|
|
|
|
if(machine.is_enabled == 0):
|
|
|
return // 已停用的机床跳过
|
|
|
|
|
|
// 2. 字段映射解析
|
|
|
fields = FieldMapper.Map(deviceObj, address.字段映射规则)
|
|
|
// fields = { program_name:"1566.NC", part_count:1219, device_status:1, ... }
|
|
|
|
|
|
// 3. 处理缺失字段
|
|
|
missingRequired = 检查必填字段缺失情况
|
|
|
if(missingRequired.Count > 0):
|
|
|
创建告警(alert_type="collect_fail", machine_id=machine.id,
|
|
|
detail="缺失字段: " + string.Join(", ", missingRequired))
|
|
|
// 缺失字段写null,不跳过整条记录
|
|
|
|
|
|
// 4. 写结构化记录(业务库 cnc_collect_record)
|
|
|
写入 CollectRecord(machine_id, collect_time=NOW(), fields)
|
|
|
|
|
|
// 5. 更新机床实时状态
|
|
|
更新 cnc_machine:
|
|
|
last_collect_time = NOW()
|
|
|
last_program_name = fields.program_name
|
|
|
last_part_count = fields.part_count
|
|
|
last_device_status = fields.device_status
|
|
|
last_run_status = fields.run_status
|
|
|
last_operate_mode = fields.operate_mode
|
|
|
last_machining_status = fields.machining_status
|
|
|
|
|
|
// 6. 产量分段追踪
|
|
|
ProductionTracker.Track(machine.id, fields.program_name, fields.part_count)
|
|
|
```
|
|
|
|
|
|
### 3.4 字段映射解析器
|
|
|
|
|
|
#### 输入
|
|
|
|
|
|
```
|
|
|
deviceObj = { "device": "fanake_1.8", "desc": "西-1.8", "tags": [...] }
|
|
|
mappings = [
|
|
|
{ standard_field: "program_name", field_name: "Tag5", match_by: "id", data_type: "string" },
|
|
|
{ standard_field: "part_count", field_name: "Tag8", match_by: "id", data_type: "number" },
|
|
|
{ standard_field: "device_status",field_name: "_io_status", match_by: "id", data_type: "number" },
|
|
|
...
|
|
|
]
|
|
|
```
|
|
|
|
|
|
#### 解析逻辑
|
|
|
|
|
|
```
|
|
|
FieldMapper.Map(deviceObj, mappings) → Dictionary<string, object>
|
|
|
|
|
|
对每条映射规则:
|
|
|
1. 在 deviceObj.tags 数组中查找匹配:
|
|
|
match_by == "id" → 找 tag.id == field_name
|
|
|
match_by == "desc" → 找 tag.desc == field_name
|
|
|
|
|
|
2. 找到则提取 tag.value:
|
|
|
data_type == "number" → 去除".00000"尾缀,转decimal
|
|
|
data_type == "string" → 直接取字符串
|
|
|
|
|
|
3. 未找到:
|
|
|
结果为 null
|
|
|
if(is_required == 1) → 加入缺失列表
|
|
|
```
|
|
|
|
|
|
#### 数值转换规则
|
|
|
|
|
|
```
|
|
|
ValueConverter.Convert(rawValue, dataType):
|
|
|
|
|
|
if(dataType == "number"):
|
|
|
// "1219.00000" → 1219
|
|
|
// "0.00000" → 0
|
|
|
// "1566.NC" → null(非数字,不抛异常)
|
|
|
str = rawValue.Trim()
|
|
|
if(decimal.TryParse(str, out result)):
|
|
|
return result
|
|
|
else:
|
|
|
return null
|
|
|
|
|
|
if(dataType == "string"):
|
|
|
return rawValue
|
|
|
```
|
|
|
|
|
|
### 3.5 产量分段追踪
|
|
|
|
|
|
#### 核心状态机
|
|
|
|
|
|
```
|
|
|
每台机床的产量追踪状态 = cnc_production_segment 表中的一条活跃记录(is_settled=0, end_time=NULL)
|
|
|
|
|
|
触发结账的事件:
|
|
|
1. NC程序名变化 → close_reason = "program_change"
|
|
|
2. 同程序下part_count下降(手动清零) → close_reason = "manual_reset"
|
|
|
3. 日终汇总时仍在运行 → close_reason = "end_of_day"
|
|
|
4. 服务停止/崩溃恢复 → close_reason = "service_stop" / "service_crash"
|
|
|
```
|
|
|
|
|
|
#### Track逻辑伪代码
|
|
|
|
|
|
```
|
|
|
ProductionTracker.Track(machineId, currentProgramName, currentPartCount):
|
|
|
|
|
|
activeSegment = repository.GetActiveSegment(machineId)
|
|
|
|
|
|
if(activeSegment == null):
|
|
|
// 无活跃段 → 开新段
|
|
|
CreateSegment(machineId, currentProgramName, currentPartCount)
|
|
|
return
|
|
|
|
|
|
if(activeSegment.program_name != currentProgramName):
|
|
|
// NC程序切换 → 结账旧段,开新段
|
|
|
quantity = activeSegment.start_part_count 不变(上一段的part_count快照)
|
|
|
// 实际产量 = currentPartCount - activeSegment.start_part_count
|
|
|
// 但注意:旧的activeSegment.start_part_count是旧程序开始时的值
|
|
|
CloseSegment(activeSegment.id,
|
|
|
endPartCount = currentPartCount,
|
|
|
quantity = currentPartCount - activeSegment.start_part_count,
|
|
|
closeReason = "program_change")
|
|
|
CreateSegment(machineId, currentProgramName, currentPartCount)
|
|
|
return
|
|
|
|
|
|
if(currentPartCount < activeSegment.start_part_count):
|
|
|
// 同程序下part_count下降 → 手动清零
|
|
|
// 结账旧段(产量 = 清零前的值 - 段起始值)
|
|
|
CloseSegment(activeSegment.id,
|
|
|
endPartCount = 上一次采集的part_count(需要内存缓存),
|
|
|
quantity = 上一次采集的part_count - activeSegment.start_part_count,
|
|
|
closeReason = "manual_reset")
|
|
|
// 开新段,从当前值(清零后的值)开始
|
|
|
CreateSegment(machineId, currentProgramName, currentPartCount)
|
|
|
return
|
|
|
|
|
|
// 正常运行 → 无需操作,活跃段继续
|
|
|
// 可选:更新内存中"上一次采集的part_count"缓存
|
|
|
```
|
|
|
|
|
|
#### part_count缓存
|
|
|
|
|
|
为了检测手动清零,需要知道**上一次采集的part_count**。在内存中维护:
|
|
|
|
|
|
```
|
|
|
Dictionary<int, decimal?> _lastPartCountByMachine // machineId → 上次part_count
|
|
|
|
|
|
每次采集后更新:
|
|
|
_lastPartCountByMachine[machineId] = currentPartCount
|
|
|
|
|
|
手动清零判断条件:
|
|
|
currentPartCount < activeSegment.start_part_count
|
|
|
(零件数比段起始值还小,说明被手动清零了)
|
|
|
```
|
|
|
|
|
|
#### A-B-C-A-B场景
|
|
|
|
|
|
```
|
|
|
时间线:
|
|
|
10:00 program=A, part=0 → 开段(A, start=0)
|
|
|
10:30 program=A, part=10 → 正常
|
|
|
11:00 program=B, part=0 → 结段(A, qty=10), 开段(B, start=0)
|
|
|
11:30 program=C, part=0 → 结段(B, qty=0), 开段(C, start=0)
|
|
|
12:00 program=A, part=0 → 结段(C, qty=0), 开段(A, start=0) ← 新段,不合并
|
|
|
12:30 program=A, part=5 → 正常
|
|
|
|
|
|
日汇总时:
|
|
|
按 (machine_id, production_date, program_name) 合并
|
|
|
program=A 的总产量 = 10 + 5 = 15
|
|
|
program=B 的总产量 = 0
|
|
|
program=C 的总产量 = 0
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 四、采集失败与重试
|
|
|
|
|
|
### 4.1 重试机制
|
|
|
|
|
|
```
|
|
|
一轮采集循环中的重试:
|
|
|
|
|
|
第1次采集 → 失败
|
|
|
sleep(retry_interval) // 默认30秒,后台可配
|
|
|
第2次采集 → 失败
|
|
|
sleep(retry_interval)
|
|
|
第3次采集 → 失败(达到 retry_count 上限,默认3次,后台可配)
|
|
|
|
|
|
重试总耗时 = retry_count × retry_interval
|
|
|
|
|
|
if(重试总耗时 < collect_interval):
|
|
|
// 正常情况:重试耗时不影响下一轮
|
|
|
fail_count++
|
|
|
sleep(collect_interval - 重试总耗时) // 等待剩余间隔
|
|
|
else:
|
|
|
// 异常情况:重试耗时超过了采集间隔
|
|
|
// 重置本轮错误状态(不算一次有效失败)
|
|
|
// 直接进入下一轮采集
|
|
|
不增加 fail_count
|
|
|
立即开始下一轮
|
|
|
|
|
|
if(fail_count >= collect_fail_alert_threshold): // 默认5次
|
|
|
创建告警(alert_type="collect_fail")
|
|
|
fail_count = 0 // 重置,避免重复告警
|
|
|
```
|
|
|
|
|
|
### 4.2 配置约束
|
|
|
|
|
|
| 配置项 | 默认值 | 说明 |
|
|
|
|--------|--------|------|
|
|
|
| `collect_retry_count` | 3 | 每轮最大重试次数 |
|
|
|
| `collect_retry_interval` | 30 | 重试间隔(秒) |
|
|
|
| `collect_fail_alert_threshold` | 5 | 连续失败N轮触发告警 |
|
|
|
|
|
|
**约束**:正常配置下 `retry_count × retry_interval < collect_interval`。如果不满足,重试超时不算失败。
|
|
|
|
|
|
### 4.3 fail_count 连续性
|
|
|
|
|
|
- 采集成功一次 → `fail_count` 立即清零
|
|
|
- 只有**连续**失败才累积
|
|
|
- 达到阈值触发告警后清零,避免同一问题重复告警
|
|
|
|
|
|
---
|
|
|
|
|
|
## 五、配置热更新
|
|
|
|
|
|
### 5.1 双保险机制
|
|
|
|
|
|
```
|
|
|
管理后台修改 → 写DB → HTTP通知采集服务 → 采集服务验证生效
|
|
|
|
|
|
┌─── 即时通知(HTTP POST /api/reload)───┐
|
|
|
│ │
|
|
|
管理后台写DB ──┤ ├──→ 采集服务重载配置
|
|
|
│ │
|
|
|
└─── 兜底轮询(每30秒查DB)─────────────────┘
|
|
|
```
|
|
|
|
|
|
### 5.2 重载流程
|
|
|
|
|
|
```
|
|
|
1. 管理后台修改采集地址/品牌模板 → 写入数据库
|
|
|
|
|
|
2. 管理后台调用采集服务API:
|
|
|
POST http://localhost:5800/api/reload
|
|
|
Headers: X-Api-Key: {collector_api_key}
|
|
|
|
|
|
3. 采集服务收到重载请求:
|
|
|
a. 标记"重载请求挂起",不立即中断
|
|
|
b. 等待所有地址当前轮采集完成
|
|
|
c. 从DB重新加载配置:
|
|
|
- cnc_collect_address(启用的地址列表)
|
|
|
- cnc_brand_field_mapping(字段映射规则)
|
|
|
- cnc_sys_config(系统配置)
|
|
|
d. 对比新旧配置,生成变更摘要
|
|
|
e. 调整运行中的任务:
|
|
|
- 新增的地址 → 启动新的Ping+采集Task
|
|
|
- 停用的地址 → 停止对应Task
|
|
|
- 变更的地址 → 停止旧Task,启动新Task
|
|
|
f. 返回变更结果
|
|
|
|
|
|
4. 管理后台验证:
|
|
|
GET http://localhost:5800/api/config/hash
|
|
|
对比本地DB配置的hash → 一致则确认生效
|
|
|
```
|
|
|
|
|
|
### 5.3 配置快照
|
|
|
|
|
|
内存中维护的配置数据结构:
|
|
|
|
|
|
```csharp
|
|
|
class CollectorConfig
|
|
|
{
|
|
|
string ConfigHash; // MD5指纹,快速判断是否变更
|
|
|
List<CollectAddress> Addresses; // 启用的采集地址列表
|
|
|
Dictionary<int, List<BrandFieldMapping>> BrandMappings; // brandId → 字段映射
|
|
|
Dictionary<string, string> SysConfig; // 系统配置键值对
|
|
|
DateTime LoadedAt; // 加载时间
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 5.4 管理后台交互
|
|
|
|
|
|
管理后台修改采集地址/品牌模板后:
|
|
|
- 保存按钮旁显示"已通知采集服务重载 ✓"或"通知失败,将在30秒后自动同步 ⚠"
|
|
|
- 通过Web API后端转发HTTP请求到采集服务(前端不直连5800端口)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 六、日终汇总
|
|
|
|
|
|
### 6.1 触发机制(启动时补检 + 内置定时器)
|
|
|
|
|
|
```
|
|
|
DailySummaryJob:
|
|
|
// 启动时自检
|
|
|
OnStart():
|
|
|
yesterday = DateTime.Today.AddDays(-1)
|
|
|
if(昨天尚未汇总):
|
|
|
立即执行昨天的汇总
|
|
|
|
|
|
// 定时检查(每分钟)
|
|
|
Timer(60秒):
|
|
|
now = DateTime.Now
|
|
|
targetDate = DateTime.Today.AddDays(-1) // 汇总昨天
|
|
|
|
|
|
if(now >= 配置的daily_summary_time): // 默认01:00
|
|
|
if(今天尚未执行过汇总):
|
|
|
执行汇总(targetDate)
|
|
|
记录"今天已汇总"
|
|
|
|
|
|
// HTTP API 手动触发
|
|
|
OnManualTrigger(date):
|
|
|
执行汇总(date) // 幂等:先删旧数据再重算
|
|
|
```
|
|
|
|
|
|
**优势**:
|
|
|
- 服务01:00挂了,02:00恢复后自动补上
|
|
|
- 手动触发支持重算任意日期
|
|
|
- 幂等设计:同一天汇总多次不会重复
|
|
|
|
|
|
### 6.2 汇总步骤
|
|
|
|
|
|
```
|
|
|
DailySummaryJob.Execute(targetDate):
|
|
|
|
|
|
for each 启用的机床:
|
|
|
// Step 1: 关闭该机床的所有活跃段
|
|
|
activeSegments = 查询(machine_id, is_settled=0, end_time=NULL)
|
|
|
for each segment in activeSegments:
|
|
|
CloseSegment(segment.id,
|
|
|
endPartCount = lastPartCount缓存或段内最大part_count,
|
|
|
quantity = endPartCount - segment.start_part_count,
|
|
|
closeReason = "end_of_day")
|
|
|
|
|
|
// Step 2: 标记结算
|
|
|
SettleByDate(targetDate)
|
|
|
|
|
|
// Step 3: 按程序合并 → cnc_daily_production
|
|
|
segments = 查询该机床targetDate的所有段
|
|
|
grouped = segments.GroupBy(s => s.program_name)
|
|
|
for each group:
|
|
|
UpsertDailyProduction(
|
|
|
machine_id, targetDate, program_name,
|
|
|
total_quantity = Sum(group.quantity),
|
|
|
segment_count = group.Count()
|
|
|
)
|
|
|
|
|
|
// Step 4: 写机床日状态
|
|
|
UpsertMachineDailyStatus(
|
|
|
machine_id, targetDate,
|
|
|
data_status = 根据当天采集情况判断:
|
|
|
"normal" → 至少成功采集过一次
|
|
|
"offline" → 当天从未Ping通
|
|
|
"data_missing" → Ping通但采集始终失败
|
|
|
)
|
|
|
|
|
|
// Step 5: 工人汇总 → cnc_worker_daily_summary
|
|
|
for each worker:
|
|
|
machines = 该工人绑定的机床列表
|
|
|
workerProductions = 查这些机床在targetDate的daily_production
|
|
|
UpsertWorkerDailySummary(
|
|
|
worker_id, targetDate,
|
|
|
total_quantity = Sum(workerProductions.total_quantity),
|
|
|
machine_count = 去重机床数,
|
|
|
program_count = 去重程序名数
|
|
|
)
|
|
|
|
|
|
// Step 6: 未开机机床插0产量记录
|
|
|
allEnabledMachines = 所有启用的机床
|
|
|
machinesWithData = 当天有daily_production记录的机床
|
|
|
for each machine in (allEnabledMachines - machinesWithData):
|
|
|
InsertDailyProduction(machine.id, targetDate, quantity=0)
|
|
|
UpsertMachineDailyStatus(machine.id, targetDate, "offline")
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 七、心跳上报
|
|
|
|
|
|
### 7.1 心跳机制
|
|
|
|
|
|
```
|
|
|
HeartbeatReporter:
|
|
|
每 heartbeat_interval 秒(默认10秒,可配):
|
|
|
|
|
|
写入 log_collector_heartbeat:
|
|
|
service_id = "CncCollector"
|
|
|
status = "running"
|
|
|
collect_address_id = null(全局心跳)
|
|
|
last_collect_time = 最近一次成功采集时间
|
|
|
success_count = 本轮成功采集次数
|
|
|
fail_count = 本轮失败采集次数
|
|
|
uptime_seconds = (NOW() - 服务启动时间).TotalSeconds
|
|
|
detail = JSON.stringify({
|
|
|
activeTasks: 活跃采集任务数,
|
|
|
activePings: 活跃Ping任务数,
|
|
|
configHash: 当前配置指纹
|
|
|
})
|
|
|
```
|
|
|
|
|
|
### 7.2 Web API判断服务状态
|
|
|
|
|
|
```sql
|
|
|
-- 管理后台/大屏查询采集服务是否在线
|
|
|
SELECT * FROM log_collector_heartbeat
|
|
|
WHERE service_id = 'CncCollector'
|
|
|
AND created_at > NOW() - INTERVAL 30 SECOND
|
|
|
ORDER BY created_at DESC LIMIT 1
|
|
|
-- 有记录 → 运行中;无记录 → 已停止
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 八、日志系统
|
|
|
|
|
|
### 8.1 双写策略
|
|
|
|
|
|
| 事件类型 | 文件日志 | DB日志(log_system) |
|
|
|
|----------|---------|-------------------|
|
|
|
| 每次采集成功 | ✅ INFO | ❌ 不写(量太大) |
|
|
|
| 采集失败/重试 | ✅ ERROR | ✅ ERROR |
|
|
|
| 字段缺失告警 | ✅ WARN | ❌(写入cnc_alert) |
|
|
|
| 未知设备告警 | ✅ WARN | ❌(写入cnc_alert) |
|
|
|
| 配置变更/重载 | ✅ INFO | ✅ INFO |
|
|
|
| 服务启停 | ✅ INFO | ✅ INFO |
|
|
|
| 日终汇总 | ✅ INFO | ✅ INFO |
|
|
|
| 异常崩溃 | ✅ FATAL | ✅ ERROR(如果还能写的话) |
|
|
|
| Ping状态变化 | ✅ DEBUG | ❌ |
|
|
|
|
|
|
### 8.2 文件日志格式
|
|
|
|
|
|
```
|
|
|
logs/collector-2026-04-30.log
|
|
|
|
|
|
[2026-04-30 01:00:02 INFO ] 日终汇总开始,目标日期:2026-04-29
|
|
|
[2026-04-30 01:00:05 INFO ] 日终汇总完成,处理160台机床,耗时3.2秒
|
|
|
[2026-04-30 01:00:30 INFO ] 地址[FANUC-1号] 采集成功,获取24台设备数据,耗时120ms
|
|
|
[2026-04-30 01:01:00 WARN ] 机床[西-1.8](id=5) 必填字段缺失: spindle_speed_set
|
|
|
[2026-04-30 01:02:00 ERROR] 地址[FANUC-2号] 采集失败(第3次重试): 连接超时
|
|
|
[2026-04-30 01:02:05 INFO ] 配置重载完成:新增地址1个,停用地址1个,更新映射3条
|
|
|
[2026-04-30 01:02:30 WARN ] 未知设备 device=fanake_1.15,采集地址[FANUC-1号]
|
|
|
```
|
|
|
|
|
|
### 8.3 log4net配置要点
|
|
|
|
|
|
```xml
|
|
|
<!-- App.config 中 log4net 配置 -->
|
|
|
<log4net>
|
|
|
<appender name="RollingFile" type="log4net.Appender.RollingFileAppender">
|
|
|
<file value="logs/collector" />
|
|
|
<datePattern value="-yyyy-MM-dd'.log'" />
|
|
|
<maximumFileSize value="50MB" />
|
|
|
<maxSizeRollBackups value="10" />
|
|
|
<layout type="log4net.Layout.PatternLayout">
|
|
|
<conversionPattern value="[%date %-5level] %message%newline" />
|
|
|
</layout>
|
|
|
</appender>
|
|
|
<root>
|
|
|
<level value="INFO" />
|
|
|
<appender-ref ref="RollingFile" />
|
|
|
</root>
|
|
|
</log4net>
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 九、优雅停止与异常恢复
|
|
|
|
|
|
### 9.1 优雅停止
|
|
|
|
|
|
```
|
|
|
核心原则:收到停止请求后,等待所有地址当前轮采集完成再退出
|
|
|
|
|
|
CollectorEngine.Stop():
|
|
|
1. 标记 _stopRequested = true
|
|
|
2. 日志记录"收到停止请求,等待当前轮采集完成"
|
|
|
3. 等待所有CollectorTask和PingTask完成(带超时)
|
|
|
Task.WaitAll(allTasks, timeout: 30秒)
|
|
|
4. 关闭所有活跃的产量分段:
|
|
|
for each 活跃段:
|
|
|
CloseSegment(reason="service_stop")
|
|
|
5. 写最终心跳(status="stopped")
|
|
|
6. 日志记录"服务已停止"
|
|
|
7. 释放资源
|
|
|
```
|
|
|
|
|
|
### 9.2 配置重载时的保护
|
|
|
|
|
|
```
|
|
|
CollectorEngine.Reload():
|
|
|
1. 标记 _reloadRequested = true
|
|
|
2. 日志记录"收到重载请求,等待当前轮采集完成"
|
|
|
3. 不中断任何正在执行的采集任务
|
|
|
4. 等待所有任务到达本轮结束点(循环顶部检查标记)
|
|
|
5. 从DB重新加载配置
|
|
|
6. 对比新旧配置,生成变更摘要
|
|
|
7. 停掉不再需要的任务
|
|
|
8. 启动新任务
|
|
|
9. 清除 _reloadRequested 标记
|
|
|
10. 返回变更摘要
|
|
|
```
|
|
|
|
|
|
### 9.3 异常停止(崩溃/强杀)的数据防护
|
|
|
|
|
|
#### 产量分段——天然事务边界
|
|
|
|
|
|
```
|
|
|
采集服务对产量分段的操作只有两种原子动作:
|
|
|
1. 开新段:INSERT → end_time=NULL
|
|
|
2. 结旧段:UPDATE → 设置end_time
|
|
|
|
|
|
最坏情况:结旧段成功但开新段失败
|
|
|
后果:机床没有活跃段
|
|
|
恢复:下次服务启动时自检恢复(见下)
|
|
|
数据丢失:最多丢失一次采集的产量,不会出现脏数据
|
|
|
```
|
|
|
|
|
|
#### 服务启动时自检恢复
|
|
|
|
|
|
```
|
|
|
CollectorEngine.OnStart():
|
|
|
1. 加载配置
|
|
|
2. 自检恢复:
|
|
|
activeSegments = 查询所有 is_settled=0 AND end_time=NULL 的段
|
|
|
for each segment:
|
|
|
if(segment.start_time 距今 > 2小时):
|
|
|
// 很可能是崩溃遗留的孤儿段
|
|
|
CloseSegment(segment.id,
|
|
|
endPartCount = segment.start_part_count,
|
|
|
quantity = 0,
|
|
|
closeReason = "service_crash",
|
|
|
endTime = segment.start_time.AddMinutes(5))
|
|
|
3. 启动Ping+采集任务
|
|
|
4. 自检日终汇总
|
|
|
```
|
|
|
|
|
|
#### 原始JSON先写后解析
|
|
|
|
|
|
```
|
|
|
采集流程中的写入顺序:
|
|
|
第1步:写入 log_collect_raw(原始JSON + is_success=1) ← 先保存
|
|
|
第2步:逐台解析 + 写 cnc_collect_record ← 后处理
|
|
|
|
|
|
如果第2步中途崩溃:
|
|
|
- 原始JSON已安全保存
|
|
|
- 可通过管理API手动触发"重解析":按log_collect_raw的记录重新跑字段映射
|
|
|
```
|
|
|
|
|
|
#### 采集记录——天然原子性
|
|
|
|
|
|
```
|
|
|
每台设备的结构化数据是一条独立的 INSERT
|
|
|
即使中途崩溃,已写入的记录是完整的
|
|
|
未写入的记录只是缺失,下次采集会补上
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 十、管理API
|
|
|
|
|
|
### 10.1 端点列表
|
|
|
|
|
|
| 方法 | URL | 说明 | 认证 |
|
|
|
|------|-----|------|------|
|
|
|
| GET | /api/status | 服务状态(运行时长、任务数、各地址状态) | X-Api-Key |
|
|
|
| POST | /api/reload | 立即重载配置(等本轮完成后执行) | X-Api-Key |
|
|
|
| POST | /api/stop | 停止所有采集任务 | X-Api-Key |
|
|
|
| POST | /api/start | 启动所有采集任务 | X-Api-Key |
|
|
|
| GET | /api/tasks | 当前运行的任务列表 | X-Api-Key |
|
|
|
| GET | /api/config/snapshot | 当前生效的配置快照 | X-Api-Key |
|
|
|
| GET | /api/config/hash | 配置指纹(MD5) | X-Api-Key |
|
|
|
| POST | /api/daily-summary | 手动触发日终汇总,参数: date=2026-04-29 | X-Api-Key |
|
|
|
| POST | /api/reparse | 重解析指定原始记录,参数: rawId=12345 | X-Api-Key |
|
|
|
|
|
|
### 10.2 认证
|
|
|
|
|
|
所有管理API请求需携带 Header `X-Api-Key`,值与 `cnc_sys_config` 表中的 `collector_api_key` 一致。
|
|
|
|
|
|
### 10.3 实现方式
|
|
|
|
|
|
使用 `System.Net.HttpListener` 自托管,独立于IIS:
|
|
|
|
|
|
```csharp
|
|
|
// ManagementServer.cs
|
|
|
HttpListener listener = new HttpListener();
|
|
|
listener.Prefixes.Add("http://+:{port}/api/");
|
|
|
listener.Start();
|
|
|
|
|
|
// 请求处理循环
|
|
|
while(running)
|
|
|
{
|
|
|
var context = listener.GetContext();
|
|
|
// 路由到 ManagementController 对应方法
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 10.4 响应格式
|
|
|
|
|
|
```json
|
|
|
{
|
|
|
"code": 0,
|
|
|
"message": "success",
|
|
|
"data": { ... }
|
|
|
}
|
|
|
```
|
|
|
|
|
|
与Web API统一格式。
|
|
|
|
|
|
---
|
|
|
|
|
|
## 十一、告警类型汇总
|
|
|
|
|
|
| alert_type | 触发条件 | detail内容 |
|
|
|
|------------|---------|------------|
|
|
|
| `collect_fail` | 连续N轮采集失败 | 失败原因摘要 |
|
|
|
| `collect_fail` | 必填字段缺失 | 缺失字段列表 |
|
|
|
| `unknown_device` | device匹配不到机床表 | 完整device JSON对象 |
|
|
|
| `device_offline` | (预留,当前Ping状态直接更新机床表) | - |
|
|
|
| `service_error` | 采集服务内部异常 | 异常消息+堆栈 |
|
|
|
|
|
|
### 告警频率控制
|
|
|
|
|
|
- 同一台机床同一个问题,5分钟内不重复告警
|
|
|
- 未知设备告警:同一device编码,1小时内不重复
|
|
|
- 告警表不记录采集成功的正常事件
|
|
|
|
|
|
---
|
|
|
|
|
|
## 十二、App.config 配置项
|
|
|
|
|
|
```xml
|
|
|
<configuration>
|
|
|
<connectionStrings>
|
|
|
<add name="BusinessConnection"
|
|
|
connectionString="Server=localhost;Database=cnc_business;Uid=root;Pwd=root;Charset=utf8mb4;SslMode=None;" />
|
|
|
<add name="LogConnection"
|
|
|
connectionString="Server=localhost;Database=cnc_log;Uid=root;Pwd=root;Charset=utf8mb4;SslMode=None;" />
|
|
|
</connectionStrings>
|
|
|
|
|
|
<appSettings>
|
|
|
<!-- 服务标识 -->
|
|
|
<add key="ServiceId" value="CncCollector" />
|
|
|
|
|
|
<!-- 管理API -->
|
|
|
<add key="ManagementApiPort" value="5800" />
|
|
|
<add key="CollectorApiKey" value="与cnc_sys_config.collector_api_key一致" />
|
|
|
|
|
|
<!-- 采集参数(首次启动默认值,后续从DB读取) -->
|
|
|
<add key="PingInterval" value="60" />
|
|
|
<add key="CollectRetryCount" value="3" />
|
|
|
<add key="CollectRetryInterval" value="30" />
|
|
|
<add key="CollectFailAlertThreshold" value="5" />
|
|
|
<add key="HeartbeatInterval" value="10" />
|
|
|
<add key="ConfigPollInterval" value="30" />
|
|
|
|
|
|
<!-- 日终汇总 -->
|
|
|
<add key="DailySummaryTime" value="01:00" />
|
|
|
|
|
|
<!-- 日志 -->
|
|
|
<add key="LogLevel" value="INFO" />
|
|
|
</appSettings>
|
|
|
</configuration>
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 十三、数据库交互清单
|
|
|
|
|
|
### 13.1 采集服务读取的表
|
|
|
|
|
|
| 表 | 库 | 用途 | 频率 |
|
|
|
|----|-----|------|------|
|
|
|
| cnc_collect_address | 业务 | 加载启用的采集地址列表 | 每30秒/重载时 |
|
|
|
| cnc_brand_field_mapping | 业务 | 加载品牌字段映射规则 | 每30秒/重载时 |
|
|
|
| cnc_brand | 业务 | 获取品牌device_field/tags_path | 每30秒/重载时 |
|
|
|
| cnc_machine | 业务 | 按device_code匹配机床 | 每次采集 |
|
|
|
| cnc_production_segment | 业务 | 查活跃段 | 每次采集每台机床 |
|
|
|
| cnc_worker_machine | 业务 | 查机床绑定的工人(日汇总用) | 日汇总时 |
|
|
|
| cnc_sys_config | 业务 | 读取系统配置 | 每30秒/重载时 |
|
|
|
| log_collect_raw | 日志 | 读取原始记录(重解析用) | 按需 |
|
|
|
|
|
|
### 13.2 采集服务写入的表
|
|
|
|
|
|
| 表 | 库 | 用途 | 频率 |
|
|
|
|----|-----|------|------|
|
|
|
| log_collect_raw | 日志 | 存原始JSON | 每次采集 |
|
|
|
| log_collector_heartbeat | 日志 | 心跳 | 每10秒 |
|
|
|
| log_system | 日志 | 关键事件日志 | 关键事件时 |
|
|
|
| cnc_collect_record | 业务 | 结构化采集记录 | 每次采集每台机床 |
|
|
|
| cnc_production_segment | 业务 | 开段/结段 | 程序切换/清零时 |
|
|
|
| cnc_daily_production | 业务 | 日汇总产量 | 日汇总时 |
|
|
|
| cnc_worker_daily_summary | 业务 | 工人日汇总 | 日汇总时 |
|
|
|
| cnc_machine_daily_status | 业务 | 机床日状态 | 日汇总时 |
|
|
|
| cnc_machine | 业务 | 更新实时状态字段 | 每次采集每台机床 |
|
|
|
| cnc_collect_address | 业务 | 更新last_collect_time/fail_count | 每次采集 |
|
|
|
| cnc_alert | 业务 | 告警记录 | 异常时 |
|
|
|
|
|
|
### 13.3 需要新增的Repository方法
|
|
|
|
|
|
以下方法在现有接口中不存在,需要新增:
|
|
|
|
|
|
```csharp
|
|
|
// ICollectRecordRepository(新建)
|
|
|
long Create(CollectRecord entity);
|
|
|
|
|
|
// IMachineRepository(补充)
|
|
|
void UpdateRealtimeStatus(int machineId, Machine realtimeFields);
|
|
|
|
|
|
// IDailyProductionRepository(补充)
|
|
|
int Upsert(DailyProduction entity); // INSERT ON DUPLICATE KEY UPDATE
|
|
|
void DeleteByMachineAndDate(int machineId, DateTime date);
|
|
|
|
|
|
// IWorkerDailySummaryRepository(补充)
|
|
|
int Upsert(WorkerDailySummary entity);
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 十四、部署与运维
|
|
|
|
|
|
### 14.1 安装为Windows Service
|
|
|
|
|
|
```bash
|
|
|
# 安装
|
|
|
installutil CncCollector.exe
|
|
|
|
|
|
# 卸载
|
|
|
installutil /u CncCollector.exe
|
|
|
|
|
|
# 或使用 sc 命令
|
|
|
sc create CncCollector binPath= "E:\opencode\haoliang\src\CncCollector\bin\Debug\CncCollector.exe"
|
|
|
sc start CncCollector
|
|
|
sc stop CncCollector
|
|
|
```
|
|
|
|
|
|
### 14.2 文件部署位置
|
|
|
|
|
|
```
|
|
|
E:\opencode\haoliang\src\CncCollector\bin\Debug\
|
|
|
├── CncCollector.exe
|
|
|
├── CncCollector.exe.config ← App.config 编译输出
|
|
|
├── CncModels.dll ← 引用的模型层
|
|
|
├── CncRepository.dll ← 引用的仓储层
|
|
|
├── Dapper.dll
|
|
|
├── MySqlConnector.dll
|
|
|
├── Newtonsoft.Json.dll
|
|
|
├── log4net.dll
|
|
|
└── logs\ ← 日志目录(自动创建)
|
|
|
```
|
|
|
|
|
|
### 14.3 与Web API共享数据库
|
|
|
|
|
|
- 采集服务和Web API同时读写同一个MariaDB
|
|
|
- 采集服务只写自己的表(采集记录、产量段、日汇总等),不会覆盖Web API管理的配置数据
|
|
|
- 配置变更通过DB传递,双保险保证一致
|
|
|
|
|
|
### 14.4 防火墙
|
|
|
|
|
|
- 开放5800端口供管理API通信(仅本机/局域网访问)
|
|
|
- 采集服务需要出站HTTP访问采集地址URL
|
|
|
- Ping需要ICMP出站权限
|
|
|
|
|
|
---
|
|
|
|
|
|
## 十五、开发顺序建议
|
|
|
|
|
|
| 步骤 | 内容 | 依赖 |
|
|
|
|------|------|------|
|
|
|
| 1 | 新建CncCollector项目 + 引用CncModels/CncRepository + App.config | 无 |
|
|
|
| 2 | FieldMapper + ValueConverter(纯函数,可独立测试) | 步骤1 |
|
|
|
| 3 | CollectorTask 单地址采集循环(HTTP拉取+解析+写库) | 步骤2 |
|
|
|
| 4 | ProductionTracker 产量分段追踪 | 步骤1 |
|
|
|
| 5 | PingTask Ping循环 | 步骤1 |
|
|
|
| 6 | CollectorEngine 引擎(启停所有Task) | 步骤3+4+5 |
|
|
|
| 7 | HeartbeatReporter 心跳 | 步骤1 |
|
|
|
| 8 | ConfigProvider 配置热更新 | 步骤6 |
|
|
|
| 9 | DailySummaryJob 日终汇总 | 步骤4 |
|
|
|
| 10 | ManagementServer + ManagementController 管理API | 步骤6+8+9 |
|
|
|
| 11 | DualLogger 双写日志 | 步骤1 |
|
|
|
| 12 | ServiceEntry + ServiceInstaller Windows Service包装 | 步骤6+10 |
|
|
|
| 13 | 集成测试(完整链路) | 全部 |
|
|
|
|
|
|
建议先跑通 **步骤1→2→3** 这条最小链路(一个地址→拉取→解析→写库),再逐步叠加其他功能。
|