feat(inspection): 新增运行事件流水表和运行状态字段

- inspection_runs 表新增 status 字段(pending/running/completed/stopped/error)
- 新增 inspection_run_events 表,记录运行过程事件时间线
- 新增 InspectionRunStatus/InspectionRunEventType 枚举和 InspectionRunEvent 模型
- IInspectionResultStore 接口新增 AppendRunEventAsync/QueryRunEventsAsync
- BeginRunAsync/CompleteRunAsync 自动记录运行事件
- 更新 CNC多检测结果归档 文档,同步新表和新字段说明
This commit is contained in:
zhengxuan.zhang
2026-05-11 11:28:44 +08:00
parent 66f49a6338
commit 368481a950
4 changed files with 291 additions and 9 deletions
+96 -6
View File
@@ -45,6 +45,8 @@
- 图像或附件索引
- `PipelineExecutionSnapshot`
- 节点执行时使用的 Pipeline 快照
- `InspectionRunEvent`
- 运行过程事件记录,用于完整回放执行时间线
默认图片保留策略:
@@ -119,7 +121,7 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
## 4. 数据库表设计
当前实现包含 5 张主表。
当前实现包含 6 张主表。
### 4.1 `inspection_runs`
@@ -134,10 +136,21 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
| `started_at` | `TEXT` | 开始时间,ISO 8601 |
| `completed_at` | `TEXT` | 结束时间,ISO 8601,可空 |
| `overall_pass` | `INTEGER` | 整体判定,`0/1` |
| `status` | `TEXT` | 运行状态:`pending / running / completed / stopped / error` |
| `source_image_path` | `TEXT` | 原图相对路径 |
| `result_root_path` | `TEXT` | 本次结果包根目录相对路径 |
| `node_count` | `INTEGER` | 节点数量 |
`status` 字段说明:
| 状态值 | 含义 |
|---|---|
| `pending` | 等待执行(默认初始值) |
| `running` | 正在执行中 |
| `completed` | 正常完成 |
| `stopped` | 用户手动停止 |
| `error` | 异常终止 |
样例数据:
| run_id | program_name | workpiece_id | serial_number | started_at | completed_at | overall_pass | source_image_path | result_root_path | node_count |
@@ -243,6 +256,42 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `Recipe-A` | `A1B2C3...` |
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `Recipe-B` | `D4E5F6...` |
### 4.6 `inspection_run_events`
用途:记录运行过程中的事件流水,用于完整回放一次 CNC 运行的时间线,便于故障排查和性能分析。
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | `INTEGER` | 主键,自增 |
| `run_id` | `TEXT` | 所属检测实例 ID |
| `node_id` | `TEXT` | 关联节点 ID,运行级事件时为空 |
| `event_type` | `TEXT` | 事件类型 |
| `event_time` | `TEXT` | 事件时间,ISO 8601 |
| `payload_json` | `TEXT` | 事件附加数据,JSON 格式,可空 |
约定的 `event_type`
| 事件类型 | 含义 |
|---|---|
| `RunStarted` | 运行开始 |
| `NodeStarted` | 节点开始执行 |
| `NodeCompleted` | 节点执行完成 |
| `NodeFailed` | 节点执行失败 |
| `RunStopped` | 运行被用户停止 |
| `RunCompleted` | 运行正常完成 |
| `RunError` | 运行异常终止 |
样例数据:
| id | run_id | node_id | event_type | event_time | payload_json |
|---:|---|---|---|---|---|
| `1` | `7d7d8d7d-...` | | `RunStarted` | `2026-04-21T10:00:00.0000000Z` | |
| `2` | `7d7d8d7d-...` | `11111111-...` | `NodeStarted` | `2026-04-21T10:00:00.1000000Z` | |
| `3` | `7d7d8d7d-...` | `11111111-...` | `NodeCompleted` | `2026-04-21T10:00:00.2350000Z` | |
| `4` | `7d7d8d7d-...` | `22222222-...` | `NodeStarted` | `2026-04-21T10:00:00.2400000Z` | |
| `5` | `7d7d8d7d-...` | `22222222-...` | `NodeFailed` | `2026-04-21T10:00:00.4800000Z` | `{"error":"Solder height out of range"}` |
| `6` | `7d7d8d7d-...` | | `RunCompleted` | `2026-04-21T10:00:03.2000000Z` | |
---
## 5. `manifest.json` 示例
@@ -261,6 +310,7 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
"StartedAt": "2026-04-21T10:00:00.0000000Z",
"CompletedAt": "2026-04-21T10:00:03.2000000Z",
"OverallPass": false,
"Status": "completed",
"SourceImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/run/source.bmp",
"ResultRootPath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeCount": 2
@@ -318,6 +368,40 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
"IsPass": false,
"DisplayOrder": 1
}
],
"Events": [
{
"Id": 1,
"RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeId": null,
"EventType": "RunStarted",
"EventTime": "2026-04-21T10:00:00.0000000Z",
"PayloadJson": ""
},
{
"Id": 2,
"RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeId": "11111111-1111-1111-1111-111111111111",
"EventType": "NodeStarted",
"EventTime": "2026-04-21T10:00:00.1000000Z",
"PayloadJson": ""
},
{
"Id": 3,
"RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeId": "11111111-1111-1111-1111-111111111111",
"EventType": "NodeCompleted",
"EventTime": "2026-04-21T10:00:00.2350000Z",
"PayloadJson": ""
},
{
"Id": 6,
"RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeId": null,
"EventType": "RunCompleted",
"EventTime": "2026-04-21T10:00:03.2000000Z",
"PayloadJson": ""
}
]
}
```
@@ -355,11 +439,12 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
得到:
- `Run`
- `Run`(含 `Status` 字段)
- `Nodes`
- `Metrics`
- `Assets`
- `PipelineSnapshots`
- `Events`(运行事件时间线)
即可直接组装报告:
@@ -395,15 +480,19 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
当前服务接口:
- `BeginRunAsync(...)`
- 创建实例记录和结果目录
- 创建实例记录和结果目录,设置状态为 `running`,记录 `RunStarted` 事件
- `AppendNodeResultAsync(...)`
- 写入节点结果、指标、图片索引、Pipeline 快照
- `CompleteRunAsync(...)`
- 回填结束时间、整体判定,并写出 `manifest.json`
- 回填结束时间、整体判定,设置状态为 `completed` 或 `stopped`,记录对应事件,并写出 `manifest.json`
- `AppendRunEventAsync(...)`
- 追加运行事件记录,用于完整回放执行时间线
- `QueryRunEventsAsync(...)`
- 查询指定运行的所有事件记录
- `QueryRunsAsync(...)`
- 查询检测实例列表
- `GetRunDetailAsync(...)`
- 查询单个实例的完整报告数据
- 查询单个实例的完整报告数据(含事件列表)
当前 DI 注册:
@@ -498,8 +587,9 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
对于后续报告模块,这套结构已经可以直接支持:
- 历史列表查询
- 历史列表查询(支持按状态筛选)
- 单次检测报告生成
- 结果图展示
- 节点级指标展示
- 历史结果可追溯
- 执行过程时间线回放(故障排查、性能分析)
@@ -10,6 +10,44 @@ namespace XplorePlane.Models
NodeResultImage
}
/// <summary>
/// 检测运行状态枚举
/// </summary>
public enum InspectionRunStatus
{
/// <summary>等待执行</summary>
Pending,
/// <summary>正在执行</summary>
Running,
/// <summary>正常完成</summary>
Completed,
/// <summary>用户停止</summary>
Stopped,
/// <summary>异常终止</summary>
Error
}
/// <summary>
/// 运行事件类型枚举
/// </summary>
public enum InspectionRunEventType
{
/// <summary>运行开始</summary>
RunStarted,
/// <summary>节点开始执行</summary>
NodeStarted,
/// <summary>节点执行完成</summary>
NodeCompleted,
/// <summary>节点执行失败</summary>
NodeFailed,
/// <summary>运行被用户停止</summary>
RunStopped,
/// <summary>运行正常完成</summary>
RunCompleted,
/// <summary>运行异常终止</summary>
RunError
}
public enum InspectionNodeStatus
{
Succeeded,
@@ -27,6 +65,7 @@ namespace XplorePlane.Models
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
public bool OverallPass { get; set; }
public InspectionRunStatus Status { get; set; } = InspectionRunStatus.Pending;
public string SourceImagePath { get; set; } = string.Empty;
public string ResultRootPath { get; set; } = string.Empty;
public int NodeCount { get; set; }
@@ -112,5 +151,19 @@ namespace XplorePlane.Models
public IReadOnlyList<InspectionMetricResult> Metrics { get; set; } = Array.Empty<InspectionMetricResult>();
public IReadOnlyList<InspectionAssetRecord> Assets { get; set; } = Array.Empty<InspectionAssetRecord>();
public IReadOnlyList<PipelineExecutionSnapshot> PipelineSnapshots { get; set; } = Array.Empty<PipelineExecutionSnapshot>();
public IReadOnlyList<InspectionRunEvent> Events { get; set; } = Array.Empty<InspectionRunEvent>();
}
/// <summary>
/// 运行事件记录,用于完整回放一次 CNC 运行的时间线
/// </summary>
public class InspectionRunEvent
{
public long Id { get; set; }
public Guid RunId { get; set; }
public Guid? NodeId { get; set; }
public InspectionRunEventType EventType { get; set; }
public DateTime EventTime { get; set; } = DateTime.UtcNow;
public string PayloadJson { get; set; } = string.Empty;
}
}
@@ -17,6 +17,16 @@ namespace XplorePlane.Services.InspectionResults
Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null);
/// <summary>
/// 追加运行事件记录,用于完整回放一次 CNC 运行的时间线。
/// </summary>
Task AppendRunEventAsync(Guid runId, Guid? nodeId, InspectionRunEventType eventType, string payloadJson = null);
/// <summary>
/// 查询指定运行的所有事件记录。
/// </summary>
Task<IReadOnlyList<InspectionRunEvent>> QueryRunEventsAsync(Guid runId);
Task<IReadOnlyList<InspectionRunRecord>> QueryRunsAsync(InspectionRunQuery query = null);
Task<InspectionRunDetail> GetRunDetailAsync(Guid runId);
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS inspection_runs (
started_at TEXT NOT NULL,
completed_at TEXT NULL,
overall_pass INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
source_image_path TEXT NOT NULL,
result_root_path TEXT NOT NULL,
node_count INTEGER NOT NULL DEFAULT 0
@@ -45,6 +46,7 @@ CREATE INDEX IF NOT EXISTS idx_inspection_runs_started_at ON inspection_runs(sta
CREATE INDEX IF NOT EXISTS idx_inspection_runs_program_name ON inspection_runs(program_name);
CREATE INDEX IF NOT EXISTS idx_inspection_runs_workpiece_id ON inspection_runs(workpiece_id);
CREATE INDEX IF NOT EXISTS idx_inspection_runs_serial_number ON inspection_runs(serial_number);
CREATE INDEX IF NOT EXISTS idx_inspection_runs_status ON inspection_runs(status);
CREATE TABLE IF NOT EXISTS inspection_node_results (
run_id TEXT NOT NULL,
@@ -99,15 +101,27 @@ CREATE TABLE IF NOT EXISTS pipeline_execution_snapshots (
pipeline_hash TEXT NOT NULL,
PRIMARY KEY (run_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_pipeline_snapshots_run_id ON pipeline_execution_snapshots(run_id);";
CREATE INDEX IF NOT EXISTS idx_pipeline_snapshots_run_id ON pipeline_execution_snapshots(run_id);
CREATE TABLE IF NOT EXISTS inspection_run_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
node_id TEXT NULL,
event_type TEXT NOT NULL,
event_time TEXT NOT NULL,
payload_json TEXT NULL,
FOREIGN KEY (run_id) REFERENCES inspection_runs(run_id)
);
CREATE INDEX IF NOT EXISTS idx_run_events_run_id ON inspection_run_events(run_id);
CREATE INDEX IF NOT EXISTS idx_run_events_event_type ON inspection_run_events(event_type);";
private const string InsertRunSql = @"
INSERT INTO inspection_runs (
run_id, program_name, workpiece_id, serial_number, started_at, completed_at,
overall_pass, source_image_path, result_root_path, node_count)
overall_pass, status, source_image_path, result_root_path, node_count)
VALUES (
@run_id, @program_name, @workpiece_id, @serial_number, @started_at, @completed_at,
@overall_pass, @source_image_path, @result_root_path, @node_count)";
@overall_pass, @status, @source_image_path, @result_root_path, @node_count)";
private const string InsertNodeSql = @"
INSERT OR REPLACE INTO inspection_node_results (
@@ -140,10 +154,19 @@ VALUES (
private const string DeleteNodeMetricsSql = "DELETE FROM inspection_metric_results WHERE run_id = @run_id AND node_id = @node_id";
private const string DeleteNodeAssetsSql = "DELETE FROM inspection_assets WHERE run_id = @run_id AND node_id = @node_id";
private const string InsertEventSql = @"
INSERT INTO inspection_run_events (run_id, node_id, event_type, event_time, payload_json)
VALUES (@run_id, @node_id, @event_type, @event_time, @payload_json)";
private const string QueryEventsSql = @"
SELECT id, run_id, node_id, event_type, event_time, payload_json
FROM inspection_run_events WHERE run_id = @run_id ORDER BY id ASC";
private const string UpdateRunSql = @"
UPDATE inspection_runs
SET completed_at = @completed_at,
overall_pass = @overall_pass,
status = @status,
node_count = @node_count,
source_image_path = @source_image_path
WHERE run_id = @run_id";
@@ -207,6 +230,7 @@ WHERE run_id = @run_id";
["started_at"] = runRecord.StartedAt.ToString("o"),
["completed_at"] = runRecord.CompletedAt?.ToString("o"),
["overall_pass"] = runRecord.OverallPass ? 1 : 0,
["status"] = runRecord.Status.ToString().ToLowerInvariant(),
["source_image_path"] = runRecord.SourceImagePath,
["result_root_path"] = runRecord.ResultRootPath,
["node_count"] = runRecord.NodeCount
@@ -217,6 +241,9 @@ WHERE run_id = @run_id";
_logger.Error(result.Exception!, "创建检测实例失败 | Failed to create inspection run: {Message}", result.Message);
throw new InvalidOperationException($"创建检测实例失败: {result.Message}", result.Exception);
}
// 记录运行开始事件
await AppendRunEventAsync(runRecord.RunId, null, InspectionRunEventType.RunStarted).ConfigureAwait(false);
}
public async Task AppendNodeResultAsync(
@@ -392,11 +419,22 @@ WHERE run_id = @run_id";
run.NodeCount = detail.Nodes.Count;
run.OverallPass = overallPass ?? detail.Nodes.All(node => node.NodePass);
// 根据 overallPass 参数确定最终状态
if (overallPass == null)
{
run.Status = InspectionRunStatus.Stopped;
}
else
{
run.Status = InspectionRunStatus.Completed;
}
var update = await _db.ExecuteNonQueryAsync(UpdateRunSql, new Dictionary<string, object>
{
["run_id"] = run.RunId.ToString("D"),
["completed_at"] = run.CompletedAt?.ToString("o"),
["overall_pass"] = run.OverallPass ? 1 : 0,
["status"] = run.Status.ToString().ToLowerInvariant(),
["node_count"] = run.NodeCount,
["source_image_path"] = run.SourceImagePath
}).ConfigureAwait(false);
@@ -407,6 +445,10 @@ WHERE run_id = @run_id";
throw new InvalidOperationException($"完成检测实例失败: {update.Message}", update.Exception);
}
// 记录运行完成/停止事件
var eventType = overallPass == null ? InspectionRunEventType.RunStopped : InspectionRunEventType.RunCompleted;
await AppendRunEventAsync(runId, null, eventType).ConfigureAwait(false);
detail.Run = run;
await WriteManifestAsync(detail).ConfigureAwait(false);
}
@@ -540,6 +582,66 @@ WHERE run_id = @run_id";
};
}
/// <summary>
/// 追加运行事件记录,用于完整回放一次 CNC 运行的时间线。
/// </summary>
public async Task AppendRunEventAsync(Guid runId, Guid? nodeId, InspectionRunEventType eventType, string payloadJson = null)
{
await EnsureInitializedAsync().ConfigureAwait(false);
var result = await _db.ExecuteNonQueryAsync(InsertEventSql, new Dictionary<string, object>
{
["run_id"] = runId.ToString("D"),
["node_id"] = nodeId?.ToString("D"),
["event_type"] = eventType.ToString(),
["event_time"] = DateTime.UtcNow.ToString("o"),
["payload_json"] = payloadJson
}).ConfigureAwait(false);
if (!result.IsSuccess)
{
_logger.Warn("记录运行事件失败 | Failed to append run event: {Message}", result.Message);
}
}
/// <summary>
/// 查询指定运行的所有事件记录。
/// </summary>
public async Task<IReadOnlyList<InspectionRunEvent>> QueryRunEventsAsync(Guid runId)
{
await EnsureInitializedAsync().ConfigureAwait(false);
var result = await _db.QueryListAsync<InspectionRunEventRow>(QueryEventsSql, new Dictionary<string, object>
{
["run_id"] = runId.ToString("D")
}).ConfigureAwait(false);
if (!result.Result.IsSuccess)
{
return Array.Empty<InspectionRunEvent>();
}
var events = new List<InspectionRunEvent>();
foreach (var row in result.Data)
{
events.Add(MapEvent(row));
}
return events;
}
private static InspectionRunEvent MapEvent(InspectionRunEventRow row)
{
return new InspectionRunEvent
{
Id = row.id,
RunId = Guid.Parse(row.run_id),
NodeId = string.IsNullOrWhiteSpace(row.node_id) ? null : Guid.Parse(row.node_id),
EventType = Enum.TryParse<InspectionRunEventType>(row.event_type, out var et) ? et : InspectionRunEventType.RunStarted,
EventTime = DateTime.Parse(row.event_time, null, System.Globalization.DateTimeStyles.RoundtripKind),
PayloadJson = row.payload_json ?? string.Empty
};
}
private async Task EnsureInitializedAsync()
{
if (_initialized)
@@ -813,6 +915,9 @@ WHERE run_id = @run_id";
runRecord.StartedAt = DateTime.UtcNow;
}
// BeginRun 时状态应为 Running
runRecord.Status = InspectionRunStatus.Running;
runRecord.ResultRootPath = Path.Combine(
"Results",
runRecord.StartedAt.ToString("yyyy"),
@@ -860,12 +965,25 @@ WHERE run_id = @run_id";
? null
: DateTime.Parse(row.completed_at, null, System.Globalization.DateTimeStyles.RoundtripKind),
OverallPass = row.overall_pass != 0,
Status = ParseRunStatus(row.status),
SourceImagePath = row.source_image_path,
ResultRootPath = row.result_root_path,
NodeCount = row.node_count
};
}
private static InspectionRunStatus ParseRunStatus(string status)
{
return status?.ToLowerInvariant() switch
{
"running" => InspectionRunStatus.Running,
"completed" => InspectionRunStatus.Completed,
"stopped" => InspectionRunStatus.Stopped,
"error" => InspectionRunStatus.Error,
_ => InspectionRunStatus.Pending
};
}
private static InspectionNodeResult MapNode(InspectionNodeRow row)
{
_ = Enum.TryParse<InspectionNodeStatus>(row.status, out var status);
@@ -957,6 +1075,7 @@ WHERE run_id = @run_id";
public string started_at { get; set; } = string.Empty;
public string completed_at { get; set; } = string.Empty;
public int overall_pass { get; set; }
public string status { get; set; } = "pending";
public string source_image_path { get; set; } = string.Empty;
public string result_root_path { get; set; } = string.Empty;
public int node_count { get; set; }
@@ -1011,5 +1130,15 @@ WHERE run_id = @run_id";
public string pipeline_definition_json { get; set; } = string.Empty;
public string pipeline_hash { get; set; } = string.Empty;
}
internal class InspectionRunEventRow
{
public long id { get; set; }
public string run_id { get; set; } = string.Empty;
public string node_id { get; set; } = string.Empty;
public string event_type { get; set; } = string.Empty;
public string event_time { get; set; } = string.Empty;
public string payload_json { get; set; } = string.Empty;
}
}
}