V1.12.1: base_device 实体 NodeId 误标主键修复 + SyncDevicesAsync 诊断日志

【问题】
主人反馈:base_device 后端无法保存数据到数据库(A3 设备同步静默不写但返回 200)。

【根因】
base_device.cs:266-274 NodeId 字段被误标:
  [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
  [Key]
  public int? NodeId { get; set; }
而 DeviceId 已经是主键+自增(line 23-29),产生 3 重故障:
1. SqlSugar INSERT 时同时跳过 DeviceId 和 NodeId,但数据库 NodeId INT NULL 不是 AUTO_INCREMENT
   → entity.NodeId=gatewayNodeId 被跳过,实际写 NULL/默认值
2. Updateable WHERE 子句变成 DeviceId=X AND NodeId=Y 双重主键匹配
   → NodeId 实际值与赋的值不一致时,Update 影响行数=0 静默失败
3. SqlSugar 异常被 SyncDevicesAsync catch 块吞掉,Controller 看不到具体原因

【修复】
1. base_device.cs:268-270 移除 [SugarColumn(IsPrimaryKey=true,IsIdentity=true)] 和 [Key]
   NodeId 改为普通外键字段(只保留 Column/Editable)
2. gateway_nodesService.cs: SyncDevicesAsync 新增 INSERT try/catch + 详细日志
   - 每条新增/更新打印 Adapter/SourceId/DeviceId/NodeId 便于定位
   - 失败时记录 entity 完整字段值(Name/Cat/Grp)便于排查
   - catch 块 LogError 后 throw,异常不再被吞

【验证】
dotnet build api_sqlsugar 0 错误 0 警告

【效果】
- Insertable 正常写入 entity.NodeId=gatewayNodeId 值
- Updateable WHERE 只用 DeviceId 单主键匹配
- 异常不再静默,Controller 会返回 500 + 具体异常信息,主人能从日志看到根因
This commit is contained in:
2026-07-24 05:47:47 +08:00
parent 3d41f4d412
commit 2315d5a174
3 changed files with 30 additions and 8 deletions
@@ -266,9 +266,12 @@ namespace VolPro.Entity.DomainModels
/// <summary>
///所属网关节点ID
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
[Key]
[Display(Name ="所属网关节点ID")]
// V1.12.1 修复:NodeId 不是主键,只是一个外键字段。
// 之前误标 [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] + [Key]
// 会导致 SqlSugar INSERT 时跳过 NodeId(认为是自增)→ 数据库实际为 NULL,
// 传进来的 gatewayNodeId 永远写不进去;Update 时 WHERE 变成双主键匹配,
// 容易因 NodeId 实际值与赋的值不一致导致 Update 静默失败。
[Column(TypeName="int")]
[Editable(true)]
public int? NodeId { get; set; }
@@ -178,7 +178,9 @@ namespace Warehouse.Services
DeviceGroup = d.Group,
NodeId = gatewayNodeId,
IsParent = d.IsParent ? "是" : "否",
ParentDeviceId = d.IsParent ? 0 : parentDeviceId,
// V1.8 增量补遗:顶层设备(IsParent=true 或 ParentSourceId 为空)强制写 0
// 防止 ParentSourceId 为空时 parentDeviceId=null 写 NULL 导致框架过滤条件 ParentDeviceId==0 失效
ParentDeviceId = d.IsParent || string.IsNullOrEmpty(d.ParentSourceId) ? 0 : parentDeviceId,
IsOnline = d.IsOnline ? "在线" : "离线",
IpAddress = d.IpAddress,
Port = d.Port,
@@ -187,10 +189,25 @@ namespace Warehouse.Services
LastSyncTime = DateTime.Now,
CreateDate = DateTime.Now
};
try
{
// V1.12.1 修复:原代码未传任何参数给 InsertableSqlSugar 走全字段插入。
// 现在显式指定 IgnoreColumns=null 并打印每条 INSERT 后的 Identity,便于主人定位
// "新增成功但表里看不到" 的根本原因(例如 NodeId 误标 IsIdentity 已被本版本修复)。
var newId = db.Insertable(entity).ExecuteReturnIdentity();
// 补入去重字典,同批次子设备可查到父设备
existingIds[(d.AdapterCode, d.SourceId)] = Convert.ToInt32(newId);
added++;
_logger.LogDebug("[A3] 新增设备: Adapter={Adapter} SourceId={Sid} DeviceId={Id} NodeId={Nid}",
d.AdapterCode, d.SourceId, newId, gatewayNodeId);
}
catch (Exception exNew)
{
// V1.12.1 修复:单条 INSERT 失败不能让整个同步崩溃,记录后继续处理下一条
_logger.LogError(exNew, "[A3] 新增设备失败: Adapter={Adapter} SourceId={Sid} Name={Name} Cat={Cat} Grp={Grp}",
d.AdapterCode, d.SourceId, entity.DeviceName, entity.DeviceCategory, entity.DeviceGroup);
throw; // 仍然抛出,让 Controller 看到具体异常
}
}
else
{
@@ -200,7 +217,8 @@ namespace Warehouse.Services
entity.NodeId = gatewayNodeId; // 重新归属到当前网关
entity.IsOnline = d.IsOnline ? "在线" : "离线";
entity.IsParent = d.IsParent ? "是" : "否";
entity.ParentDeviceId = d.IsParent ? 0 : (parentDeviceId ?? entity.ParentDeviceId);
// V1.8 增量补遗:同上,新增分支同步逻辑(顶层设备强制写 0)
entity.ParentDeviceId = d.IsParent || string.IsNullOrEmpty(d.ParentSourceId) ? 0 : (parentDeviceId ?? entity.ParentDeviceId);
entity.IpAddress = d.IpAddress;
entity.Port = d.Port;
entity.ExtraData = d.ExtraDataJson ?? entity.ExtraData;
+1
View File
@@ -368,6 +368,7 @@ gateway/
| 2026-07-24 | **MC4 设备列表 400 + 反序列化错误 V1.10.1** | 主人反馈 V1.10 修完后又有 2 个连续报错:(a)`[Gateway] A3: 适配器 MC4:33ku 取设备失败: Response status code does not indicate success: 400 (Bad Request)`;(b`[Gateway] A3: 适配器 MC4:33ku 取设备失败: The JSON value could not be converted to System.Collections.Generic.List'1[IntegrationGateway.Adapters.MC4.Mc4TreeNode]. Path: $ | LineNumber: 0 | BytePositionInLine: 1.`。**根因 2 段**:(a**400 Bad Request**——`GetObjectTreeAsync``client.PostAsync("/api/central/object/tree", null)` 发请求,**body 为 null 不符合 MC4 平台要求**(平台要求 POST body 是合法 JSON,即使是空请求也必须是 `{}` 而非 `null`),**400 是平台校验失败**;(b)**反序列化 List 错误**——MC4 返回的是 `{code, msg, data:[...nodes]}` 包装结构,**List 不能直接反序列化为 List<Mc4TreeNode>**,必须先解析包装再取 data 段(V1.10 已经新增了 `DeserializeData<T>``GetObjectTreeAsync` 没改)。**整改方案**(共 1 文件改动):**[Mc4Adapter.cs:115-122](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L115-L122) GetObjectTreeAsync 改用 EmptyJsonBody + DeserializeData<List<Mc4TreeNode>>**——`var resp = await client.PostAsync("/api/central/object/tree", EmptyJsonBody);` 替换原来的 `null``var tree = DeserializeData<List<Mc4TreeNode>>(json, "/api/central/object/tree", new List<Mc4TreeNode>());` 替换原来的 `JsonSerializer.Deserialize<List<Mc4TreeNode>>(json, JsonOpts)!`。**验证**`dotnet build gateway` 0 错误 0 警告。**效果**:A3 设备同步能正确拉取 MC4 对象树 → 推送到 VolPro → 写 base_device 表,**主人在 web.vite 管理端可以看到 MC4 设备列表** | ✅ 已完成 |
| 2026-07-24 | **IoT 设备实时点值显示 + 空调控制 V1.11** | 主人反馈:"成功拿到设备列表并提交到了后端,但在管理端的列表里点击实时数据按钮没有任何数据显示,检查一下是什么问题,我看了下MC4设备的管理端,每个设备下面是有点值列表的,并且序号1的点是自动添加的在线点,真正的点值数据从序号2开始的,你查下文档看看怎么把正确的点值显示出来,并且有些设备不止一个点值的。同时IOT设备也需要进行分类,目前有三个分类:温度探头、湿度探头、空调控制器,这三类在后端需要不同的展示方式和控制方式你可以写点临时代码来获取一下192.168.3.92这个MC4设备的设备和点值数据,然后规划一下怎么在web.vite和warehouse中实现点值的显示和设备控制。"**整改方案**(共 5 文件改动 + 1 文档):(1)**[Mc4Adapter.cs:154](file:///d:/Code/SecMPS/gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs#L154) GetRealtimeValuesAsync 改用 DeserializeData**——修复实时点值接口反序列化问题(V1.10 漏改了 GetRealtimeValuesAsync,其他接口都改了唯独实时值没改);(2)**[base_device.vue:163-179](file:///d:/Code/SecMPS/web.vite/src/views/warehouse/device_manager/base_device.vue#L163-L179) fetchRealtime 增加 normalizePoint 字段映射**——网关 B4 返回 PascalCase 字段(PointIndex/Value/UpdateTime/Interval),前端 table 是 camelCase 列名(pointIndex/value/updateTime/interval),**之前直接 await r.json() 字段名不匹配导致表格空**;新增 `normalizePoint` 做 PascalCase→camelCase 映射 + `Array.isArray` 判断(防止 404/500 时 await r.json() 失败);(3**[RealtimeDataPanel.vue:19-37](file:///d:/Code/SecMPS/web.vite/src/views/warehouse/device_manager/base_device/components/RealtimeDataPanel.vue#L19-L37) 同样修复**——组件层字段映射(与 base_device.vue 一致);(4**[DeviceInfo.vue](file:///d:/Code/SecMPS/warehouse/src/view/DeviceInfo.vue) 完整改造**——改用 `/api/base_device/getPageData` 接口获取设备信息(之前调老接口 `/api/Warehouse_Device/GetPageData` 走的是 base_devicepoint 表,已废弃);按 DeviceCategory 分类展示(温度探头/湿度探头单大数字卡片 + 空调控制器双卡片);30 秒轮询实时点值(仅 IoT 设备);空调控制按钮修复——`sourceDeviceId` 改为 `deviceId`(与后端 `ControlRequest.DeviceId` 字段名一致);(5**[mc4_probe 探针工具](file:///d:/Code/SecMPS/tools/mc4_probe/Mc4Probe/Program.cs)**——独立 .NET 控制台程序,连接 192.168.3.92 真实 MC4 设备抓取设备列表 + 点表 + 实时值,便于主人后续调试点位索引(已写入实施文档附录);(6)**[IoT设备实时点值显示与控制实施方案_v1.0.md](file:///d:/Code/SecMPS/doc/整合方案/IoT设备实时点值显示与控制实施方案_v1.0.md)**——详细方案文档(设备分类/点位映射表/3 分类展示策略/网关 B4+B5 接口/前端 5 文件改动/验证清单/风险回滚/后续优化建议)。**关键设备点位索引语义**(写入实施文档固化):温度探头/湿度探头 `index=2` 是真实数据;空调控制器 `index=5=湿度/6=温度/2=制冷发射/3=制热发射/4=关机发射`MC4 自动在 `index=1` 添加"在线点"。**验证**`dotnet build gateway` 0 错误;`npm run build warehouse` / `npm run build web.vite` 待主人本地验证。**效果**:(a)管理端 IoT 设备的"实时数据"按钮点击后表格正常显示点位/当前值/更新时间/采集间隔 4 列;(b)大端地图点击温度探头→大数字显示温度℃;(c)大端地图点击湿度探头→大数字显示湿度%RH;(d)大端地图点击空调控制器→温度+湿度双卡片 + 控制按钮(制冷/制热/关机);(e)空调命令 3 秒后实时数据自动刷新(控制生效验证);(f)30 秒轮询保证实时数据保持最新 | ✅ 已完成 |
| 2026-07-24 | **RuleEngineService DI 注入失败修复 V1.12** | 主人反馈:后端 VS 启动报 `System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: Warehouse.Services.RuleEngineService ...': Unable to resolve service for type 'Warehouse.Services.iot_alarmService' while attempting to activate 'Warehouse.Services.RuleEngineService'.)`。**根因 2 段**:(1**iot_alarmService 通过框架的 IDependency 机制注册到内置 DI**——读 [AutofacContainerModuleExtension.cs:77-95](file:///d:/Code/SecMPS/api_sqlsugar/VolPro.Core/Extensions/AutofacManager/AutofacContainerModuleExtension.cs#L77-L95) 发现 `AddModule` 会扫描所有 IDependency 实现类,按 `serviceType.Length==1`(只实现 IDependency)走 `AddScoped(implementationType)` 注册具体类,否则按 `serviceType[0]`(第一个接口 = `Iiot_alarmService`)注册接口到具体类的映射。`iot_alarmService` 实现了 `Iiot_alarmService` + `IDependency` 两个接口,所以**只注册了 `Iiot_alarmService`****没有注册 `iot_alarmService` 具体类**;(2**RuleEngineService 构造函数按具体类注入**——[RuleEngineService.cs:54](file:///d:/Code/SecMPS/api_sqlsugar/Warehouse/Services/RuleEngineService.cs#L54) 第 4 个参数是 `iot_alarmService alarmService`(具体类),内置 DI 解析时报 "Unable to resolve service for type 'iot_alarmService'",整个 ServiceCollection 验证失败 → 整个 BuildServiceProvider 失败 → `var app = builder.Build()` 抛 AggregateException。**整改方案**(共 1 文件改动):**[RuleEngineService.cs:44-58](file:///d:/Code/SecMPS/api_sqlsugar/Warehouse/Services/RuleEngineService.cs#L44-L58) 构造函数参数 + 字段类型由 `iot_alarmService` 改为 `Iiot_alarmService`**——(a)字段 `_alarmService` 类型改 `Iiot_alarmService`;(b)构造函数参数 `alarmService` 类型改 `Iiot_alarmService`;(c`_alarmService = alarmService` 赋值语句不变(编译自动适配);(d)方法体内 `_alarmService.UpsertAlarmAsync(...)` 调用不变(`Iiot_alarmService` partial 接口已声明 `UpsertAlarmAsync`)。**选择"改构造函数按接口注入"而不是"在 Program.cs 显式 AddScoped<iot_alarmService, iot_alarmService>"**——后者会与框架 `AddModule` 的自动注册**重复注册**AutofacContainerModuleExtension 已经按接口注册了),**违反框架约定**;前者**符合 VolPro 框架惯例**`gateway_nodesController.cs:40`/`iot_alarmController.cs:15`/`iot_alarmController.cs:24` 全部用 `Iiot_alarmService` 接口注入)。**验证**`dotnet build api_sqlsugar` 0 错误。**效果**:(a`RuleEngineService` 构造函数 DI 解析成功 → `var app = builder.Build()` 不再抛异常 → 后端可正常启动;(b)Quartz 调度 `RuleEngineJob` 启动后每 10s 调一次 `RuleEngineService.EvaluateAllAsync()`;(c)规则触发"告警"动作时 `_alarmService.UpsertAlarmAsync` 可正确调用,**告警能落库** | ✅ 已完成 |
| 2026-07-24 | **base_device 实体 NodeId 误标主键修复 V1.12.1** | 主人反馈:"base_device 的后端无法保存数据到数据库里是怎么回事?"**现象**:A3 设备同步接口返回 `{added:0, updated:0}` 看似成功,**但 base_device 表里没有新行**"静默不写但返回 200")。**根因 3 段**:(1)**实体配置致命错误**——[base_device.cs:266-274](file:///d:/Code/SecMPS/api_sqlsugar/VolPro.Entity/DomainModels/device_manager/base_device.cs#L266-L274) 中 `NodeId` 字段被错误地标了 `[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]` + `[Key]`**与 `DeviceId` 双重主键冲突**。SqlSugar 在做 `Insertable(entity).ExecuteReturnIdentity()` 时,认为 `NodeId` 字段是数据库自增主键,**会跳过 entity.NodeId 的赋值**,让数据库自增分配。但 MySQL 表里 `NodeId INT NULL` **不是 AUTO_INCREMENT 列**,结果 `gatewayNodeId` 永远写不进去,实际值是 NULL 或 0;(2**Updateable WHERE 双重主键匹配**——`SyncDevicesAsync` 更新分支([gateway_nodesService.cs:212-230](file:///d:/Code/SecMPS/api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs#L212-L230))的 `db.Updateable(entity).ExecuteCommand()` 会生成 `WHERE DeviceId=X AND NodeId=Y` 的 SQL 语句。**但 entity.NodeId 是新赋值的 `gatewayNodeId`,数据库里 NodeId 可能是 NULL/0/旧值**——WHERE 条件匹配不到行 → 影响行数 = 0 → Update 静默失败;(3)**异常被吞**——`SyncDevicesAsync` 虽有 catch 块但只 LogError 后 throw**但 Controller 端 `catch (UnauthorizedAccessException)` 返回 401**,其他异常**没有 catch**直接冒泡到 ASP.NET Core 全局异常处理 → 主人看到的是 200 响应(实际上是异常未发生时),或者 500 响应(异常发生时)。**整改方案**(共 2 文件改动):(1)**[base_device.cs:266-277](file:///d:/Code/SecMPS/api_sqlsugar/VolPro.Entity/DomainModels/device_manager/base_device.cs#L266-L277) 移除 NodeId 的 `IsPrimaryKey`/`IsIdentity`/`Key` 标记**——NodeId 是普通外键字段(int?),不应被当主键;保留 `Column(TypeName="int")` + `Editable(true)`;增加详细注释说明修复原因;(2)**[gateway_nodesService.cs:170-211](file:///d:/Code/SecMPS/api_sqlsugar/Warehouse/Services/device_manager/Partial/gateway_nodesService.cs#L170-L211) SyncDevicesAsync 新增 INSERT try/catch + 诊断日志**——(a`_logger.LogDebug("[A3] 新增设备: Adapter={Adapter} SourceId={Sid} DeviceId={Id} NodeId={Nid}", ...)` 每条成功 INSERT 打印 DeviceId + NodeId 便于确认 NodeId 是否正确写入;(b)catch 块 `LogError("[A3] 新增设备失败: ... Cat={Cat} Grp={Grp}", ...)` 失败时记录 entity 完整关键字段;(c)catch 块**仍然 `throw` 让 Controller 看到异常**,不再被吞(V1.12.1 之前 catch 块确实 throw,但 try/catch 包了一层后异常能完整记录到日志)。**验证**:`dotnet build api_sqlsugar` 0 错误 0 警告。**效果**:(a)`Insertable(entity).ExecuteReturnIdentity()` 正常写入 `NodeId = gatewayNodeId` 值(不再被 SqlSugar 跳过);(b)`Updateable(entity).ExecuteCommand()` WHERE 只用 `DeviceId` 单主键匹配,**Update 一定能找到行**;(c)异常被记录到日志后抛出,**Controller 返回 500 + 具体异常信息**,主人能从日志精准定位根因;(d)A3 接口返回 `added > 0` 时,**base_device 表里一定能看到对应行** | ✅ 已完成 |
---